@@ -79,7 +75,7 @@ const EmployerEditor = ({
const { industries, universities, get_university_by_name } = useDbRefs();
const [isRegistering, setIsRegistering] = useState(false);
const [additionalFields, setAdditionalFields] = useState(
- {} as AdditionalFields
+ {} as AdditionalFields,
);
const [missingFields, setMissingFields] = useState([]);
@@ -87,7 +83,10 @@ const EmployerEditor = ({
// Validate required fields before submitting
const newMissing: string[] = [];
- if (!formData.legal_entity_name || formData.legal_entity_name.trim().length < 3) {
+ if (
+ !formData.legal_entity_name ||
+ formData.legal_entity_name.trim().length < 3
+ ) {
newMissing.push("Legal entity name");
}
if (
@@ -158,25 +157,25 @@ const EmployerEditor = ({
useEffect(() => {
addValidator(
"name",
- (name: string) => name && name.length < 3 && `Company Name is not valid.`
+ (name: string) => name && name.length < 3 && `Company Name is not valid.`,
);
addValidator(
"website",
(link: string) =>
- link && !isValidRequiredURL(link) && "Invalid website link."
+ link && !isValidRequiredURL(link) && "Invalid website link.",
);
addValidator(
"phone_number",
(number: string) =>
- number && !isValidPHNumber(number) && "Invalid PH number."
+ number && !isValidPHNumber(number) && "Invalid PH number.",
);
addValidator(
"email",
- (email: string) => email && !isValidEmail(email) && "Invalid email."
+ (email: string) => email && !isValidEmail(email) && "Invalid email.",
);
addValidator(
"location",
- (location: string) => !location && `Provide your main office's location.`
+ (location: string) => !location && `Provide your main office's location.`,
);
}, []);
@@ -195,18 +194,18 @@ const EmployerEditor = ({
Register
{missingFields.length > 0 && (
-
+
You need to provide values for these fields:
{missingFields.map((field) => (
-
- {field}
-
+ {field}
))}
@@ -227,7 +226,9 @@ const EmployerEditor = ({
})
}
className={cn(
- missingFields.find((field) => field === "Contact name") ? "border-destructive" : ""
+ missingFields.find((field) => field === "Contact name")
+ ? "border-destructive"
+ : "",
)}
/>
@@ -236,7 +237,9 @@ const EmployerEditor = ({
value={formData.phone_number ?? ""}
setter={fieldSetter("phone_number")}
className={cn(
- missingFields.find((field) => field === "Phone number") ? "border-destructive" : ""
+ missingFields.find((field) => field === "Phone number")
+ ? "border-destructive"
+ : "",
)}
/>
@@ -247,7 +250,9 @@ const EmployerEditor = ({
value={formData.email ?? ""}
setter={fieldSetter("email")}
className={cn(
- missingFields.find((field) => field === "Contact email") ? "border-destructive" : ""
+ missingFields.find((field) => field === "Contact email")
+ ? "border-destructive"
+ : "",
)}
/>
@@ -258,7 +263,9 @@ const EmployerEditor = ({
value={formData.website ?? ""}
setter={fieldSetter("website")} // invalid type
className={cn(
- missingFields.find((field) => field === "Company website") ? "border-destructive" : ""
+ missingFields.find((field) => field === "Company website")
+ ? "border-destructive"
+ : "",
)}
/>
@@ -275,7 +282,9 @@ const EmployerEditor = ({
required={true}
maxLength={100}
className={cn(
- missingFields.find((field) => field === "Legal entity name") ? "border-destructive" : ""
+ missingFields.find((field) => field === "Legal entity name")
+ ? "border-destructive"
+ : "",
)}
/>
@@ -295,7 +304,9 @@ const EmployerEditor = ({
setter={fieldSetter("location")}
maxLength={100}
className={cn(
- missingFields.find((field) => field === "Main office city") ? "border-destructive" : ""
+ missingFields.find((field) => field === "Main office city")
+ ? "border-destructive"
+ : "",
)}
/>
@@ -337,7 +348,13 @@ const EmployerEditor = ({
- Need help? Contact us at 0927 660 4999 or on Viber .
+ Need help? Contact us at{" "}
+
+ 0927 660 4999
+ {" "}
+ or on{" "}
+
+ Viber
+
+ .
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..3700d0ac
--- /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 (
+ <>
+
+
{
+ if (form.document.url) {
+ modalRegistry.previewFormPdf.open({
+ documentUrl: form.document.url,
+ });
+ } else {
+ alert("No document url provided for preview.");
+ }
+ }}
+ >
+
+ Preview PDF
+
+
+
+ {recipients.some(
+ (recipient) => recipient.signatory_source?._id === "initiator",
+ )
+ ? "Sign via BetterInternship"
+ : "Fillout Document"}
+
+
+
+
+ or print for wet signature instead
+
+ >
+ );
+};
diff --git a/app/student/forms/components/FormDashboard.tsx b/app/student/forms/components/FormDashboard.tsx
new file mode 100644
index 00000000..0c6fb20c
--- /dev/null
+++ b/app/student/forms/components/FormDashboard.tsx
@@ -0,0 +1,464 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, 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 { FormFillerContextProvider } from "@/components/features/student/forms/form-filler.ctx";
+import { SignContextProvider } 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";
+
+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;
+};
+
+export default function FormDashboard({
+ generatedForms,
+ formTemplates,
+ isLoading,
+}: {
+ generatedForms: GeneratedFormItem[];
+ formTemplates: FormTemplate[];
+ isLoading: boolean;
+}) {
+ const { isMobile } = useMobile();
+ const modalRegistry = useModalRegistry();
+ 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 form = useFormRendererContext();
+ const recipients = form.formMetadata.getSigningParties();
+ 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(
+ () =>
+ (generatedForms ?? []).some(
+ (entry) => entry.label === form.formLabel || !form.formLabel,
+ ),
+ [generatedForms, form.formLabel],
+ );
+
+ 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,
+ generatedForms,
+ isMobileSigningFlow,
+ isMobileExitConfirmationOpen,
+ form,
+ form.loading,
+ form.document.name,
+ form.document.url,
+ form.formLabel,
+ ]);
+
+ 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.
+
+
+
+ Continue editing
+
+
+ Exit
+
+
+
+
+ )}
+
+ );
+}
diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx
new file mode 100644
index 00000000..fa4c96d4
--- /dev/null
+++ b/app/student/forms/components/FormSigningLayout.tsx
@@ -0,0 +1,753 @@
+"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 { cn, isValidEmail } from "@/lib/utils";
+import { useCallback, useEffect, useMemo, 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";
+
+interface FlowTestSigningLayoutProps {
+ formLabel?: string;
+ documentUrl?: string;
+ recipients: IFormSigningParty[];
+ noEsign?: boolean;
+ onBack: () => void;
+}
+
+type SigningStep =
+ | "preview-start"
+ | "timeline"
+ | "fields"
+ | "preview-review"
+ | "confirm";
+
+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 { isMobile } = useAppContext();
+ const hasInitiatorRecipient = recipients.some(
+ (recipient) => recipient.signatory_source?._id === "initiator",
+ );
+ const initialNoRecipientStep = !hasInitiatorRecipient || !!noEsign;
+ const initialStep: SigningStep = isMobile
+ ? "preview-start"
+ : initialNoRecipientStep
+ ? "fields"
+ : "timeline";
+ const [values, setValues] = useState({});
+ const [nextLoading, setNextLoading] = useState(false);
+ const [recipientEmails, recipientEmailActions] = useStateRecord({});
+ const [recipientErrors, recipientErrorActions] = useStateRecord({});
+ const [hasConfirmedDetails, setHasConfirmedDetails] = useState(false);
+ const [previousStep, setPreviousStep] = useState(initialStep);
+ const [currentStep, setCurrentStep] = useState(initialStep);
+ const [selectedFieldSource, setSelectedFieldSource] = useState<
+ "form" | "pdf" | null
+ >(null);
+ const [selectionTick, setSelectionTick] = useState(0);
+
+ 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
+ ? ["preview-start", "fields", "preview-review", "confirm"]
+ : ["preview-start", "timeline", "fields", "preview-review", "confirm"];
+ const resetStep: SigningStep = isMobile
+ ? "preview-start"
+ : noRecipientStep
+ ? "fields"
+ : "timeline";
+ const steps = isMobile ? mobileSteps : desktopSteps;
+ const isMobilePreviewStep =
+ isMobile &&
+ (currentStep === "preview-start" || currentStep === "preview-review");
+ const stepNumber = Math.max(steps.indexOf(currentStep) + 1, 1);
+ const mobileStepIndexByStep = useMemo(
+ () => new Map(mobileSteps.map((step, index) => [step, index])),
+ [mobileSteps],
+ );
+ const mobileStepPaneHiddenClass = "opacity-0 pointer-events-none";
+
+ 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"
+ : "translate-x-6 opacity-0 pointer-events-none";
+ },
+ [currentStep, mobileStepIndexByStep],
+ );
+
+ const goToStep = useCallback(
+ (nextStep: SigningStep) => {
+ setPreviousStep(currentStep);
+ setCurrentStep(nextStep);
+ },
+ [currentStep],
+ );
+
+ const previewKeyedFields = useMemo(
+ () =>
+ (form.keyedFields ?? []).map((field) => ({
+ ...field,
+ signing_party_id: fieldOwnerByName.get(field.field),
+ })),
+ [form.keyedFields, fieldOwnerByName],
+ );
+
+ const handlePdfFieldSelect = (fieldName: string) => {
+ setSelectedFieldSource("pdf");
+ setSelectionTick((prev) => prev + 1);
+ form.setSelectedPreviewId(fieldName);
+ if (currentStep !== "fields") {
+ goToStep("fields");
+ }
+ };
+
+ const handleFormFieldSelect = (fieldName: string) => {
+ setSelectedFieldSource("form");
+ setSelectionTick((prev) => prev + 1);
+ form.setSelectedPreviewId(fieldName);
+ };
+
+ const nextEnabled = useMemo(() => {
+ console.log(
+ "VALS",
+ form.fields.reduce(
+ (acc, cur) => ((acc[cur.field] = values[cur.field]), acc),
+ {} as Record,
+ ),
+ form.fields.every(
+ (field) =>
+ field.signing_party_id !== "initiator" ||
+ field.source !== "manual" ||
+ !!values[field.field],
+ ),
+ );
+ switch (currentStep) {
+ case "preview-start":
+ return true;
+ case "timeline":
+ return recipients.every(
+ (recipient) =>
+ !!recipientEmails[
+ form.formMetadata.getSigningPartyFieldName(recipient._id)
+ ] || recipient.signatory_source?._id !== "initiator",
+ );
+ case "fields":
+ return form.fields.every(
+ (field) =>
+ field.signing_party_id !== "initiator" ||
+ field.source !== "manual" ||
+ !!values[field.field],
+ );
+ case "preview-review":
+ return true;
+ case "confirm":
+ return true;
+ }
+ }, [currentStep, recipients, recipientEmails, form, values]);
+
+ const checkRecipientErrors = (recipientEmails: Record) => {
+ const emailErrors: Record = {};
+
+ for (const [recipientKey, recipientEmail] of Object.entries(
+ recipientEmails,
+ )) {
+ if (!isValidEmail(recipientEmail))
+ emailErrors[recipientKey] = `${recipientEmail} is not a valid email.`;
+ }
+
+ return emailErrors;
+ };
+
+ const handleNext = useCallback(async () => {
+ const additionalValues = { ...autofillValues, ...recipientEmails };
+ const finalValues = formFiller.getFinalValues(additionalValues);
+ const errors = formFiller.validate(form.fields, additionalValues);
+ const emailErrors = checkRecipientErrors(recipientEmails);
+
+ // So it doesn't look like it's hanging
+ setNextLoading(true);
+
+ switch (currentStep) {
+ case "preview-start":
+ goToStep(noRecipientStep ? "fields" : "timeline");
+ setNextLoading(false);
+ break;
+ case "timeline":
+ if (Object.keys(emailErrors).length) {
+ recipientErrorActions.overwrite(emailErrors);
+ toast.error(
+ "Some information is missing or incorrect",
+ toastPresets.destructive,
+ );
+ } else {
+ recipientErrorActions.clearAll();
+ goToStep("fields");
+ }
+
+ setNextLoading(false);
+ break;
+ case "fields":
+ if (Object.keys(errors).length) {
+ toast.error(
+ "Some information is missing or incorrect",
+ toastPresets.destructive,
+ );
+ } else {
+ await updateAutofill(form.formName, form.fields, finalValues);
+ goToStep(isMobile ? "preview-review" : "confirm");
+ }
+
+ setNextLoading(false);
+ break;
+ case "preview-review":
+ goToStep("confirm");
+ setNextLoading(false);
+ break;
+ case "confirm":
+ setNextLoading(false);
+ break;
+ }
+ }, [
+ autofillValues,
+ currentStep,
+ form,
+ formFiller,
+ isMobile,
+ noRecipientStep,
+ recipientEmailActions,
+ 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],
+ },
+ };
+ }, [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", () => {
+ setPreviousStep(initialStep);
+ 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",
+ () => {
+ setPreviousStep(initialStep);
+ 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();
+ setValues({});
+ setHasConfirmedDetails(false);
+ setPreviousStep(initialStep);
+ setCurrentStep(initialStep);
+ }, [formLabel, initialStep, noEsign]);
+
+ useEffect(() => {
+ if (currentStep === "confirm") {
+ setHasConfirmedDetails(false);
+ }
+ }, [currentStep]);
+
+ return (
+
+
+
+ {isMobile && (
+
+
+ Step {stepNumber} of {steps.length}
+
+
+ )}
+
+
+ {documentUrl ? (
+ <>
+ {isMobile && currentStep === "preview-review" && (
+
+
+ Please review your inputs
+
+
+ )}
+
{
+ setPreviousStep(initialStep);
+ setCurrentStep(initialStep);
+ onBack();
+ }}
+ className="h-8 gap-2 px-2 text-xs text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
+ >
+
+ Back to Templates
+
+ ) : undefined
+ }
+ fieldErrors={formFiller.errors}
+ selectionTick={selectionTick}
+ autoScrollToSelectedField={
+ !isMobile && selectedFieldSource === "form"
+ }
+ signingParties={recipients}
+ onFieldClick={handlePdfFieldSelect}
+ selectedFieldId={form.selectedPreviewId ?? undefined}
+ />
+ >
+ ) : (
+
+ PDF preview unavailable.
+
+ )}
+ {isMobilePreviewStep && (
+
+
+ {currentStep === "preview-review" && (
+
goToStep("fields")}
+ aria-label="Back to form fields"
+ >
+
+
+ )}
+
void handleNext()}
+ >
+ Next
+
+
+
+ )}
+
+
+
+ {!isMobile && (
+
+
+
+ {formLabel}
+
+
+
+ Step {stepNumber} of {steps.length}
+
+
+ )}
+
+
+
+
+
+ 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.
+
+
+ )}
+
+
+
+
+
+ {isMobile && (
+
goToStep("preview-start")}
+ aria-label="Back"
+ >
+
+
+ )}
+
void handleNext()}
+ >
+ Next
+
+
+
+
+
+
+
+
+
+
+
+ {isMobile ? (
+
+
+ goToStep(
+ noRecipientStep ? "preview-start" : "timeline",
+ )
+ }
+ aria-label="Back"
+ >
+
+
+
void handleNext()}
+ >
+ Next
+
+
+ ) : (
+
+ goToStep("timeline")}
+ >
+ Back
+
+ void handleNext()}
+ >
+ Next
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ Please check if all your inputs are correct
+
+ {!noRecipientStep && (
+
+ )}
+
+
+ setHasConfirmedDetails(checked === true)
+ }
+ />
+ I confirm all the details are correct
+
+
+
+ {isMobile ? (
+
+
+ goToStep(isMobile ? "preview-review" : "fields")
+ }
+ aria-label="Go back and edit details"
+ >
+
+
+
void handleSubmit()}
+ >
+
+ Submit
+
+
+
+ ) : (
+
+ goToStep("fields")}
+ >
+ Go back and edit details
+
+ void handleSubmit()}
+ >
+
+ Submit
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/student/forms/components/FormSigningPartyTimeline.tsx b/app/student/forms/components/FormSigningPartyTimeline.tsx
new file mode 100644
index 00000000..7708bc3e
--- /dev/null
+++ b/app/student/forms/components/FormSigningPartyTimeline.tsx
@@ -0,0 +1,86 @@
+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";
+
+export const FormSigningPartyTimeline = ({
+ recipientInputAPI,
+ isConfirmingRecipients,
+}: {
+ recipientInputAPI?: {
+ recipientEmails: StateRecord;
+ recipientErrors: StateRecord;
+ recipientEmailActions: StateRecordActions;
+ };
+ isConfirmingRecipients?: boolean;
+}) => {
+ const form = useFormRendererContext();
+ const recipients = form.formMetadata.getSigningParties();
+
+ return (
+ recipients.length > 1 && (
+
+ {recipients.map((recipient, index) => {
+ 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.recipientEmailActions.setOne(
+ fieldName,
+ value,
+ )
+ }
+ />
+ )
+ ) : 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 (
+
{
+ setSelectedTemplate(template);
+ form.updateFormName(template.formName);
+ }}
+ className="group w-full text-left"
+ >
+
+
+
+
+ {template.formLabel}
+
+
+
+
+
+
+ );
+ })}
+
+ );
+};
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 = ({
-
-
-
-
-
-
+
+
+
+
diff --git a/app/student/profile/page.tsx b/app/student/profile/page.tsx
index d09bc397..901eef13 100644
--- a/app/student/profile/page.tsx
+++ b/app/student/profile/page.tsx
@@ -789,7 +789,6 @@ const ProfileEditor = forwardRef<
},
};
updateProfile(updatedProfile);
- await qc.refetchQueries({ queryKey: ["my-profile"] });
return true;
},
}));
diff --git a/app/student/register/page.tsx b/app/student/register/page.tsx
index db8d2b1d..ea94dfe4 100644
--- a/app/student/register/page.tsx
+++ b/app/student/register/page.tsx
@@ -223,6 +223,9 @@ export default function RegisterPage() {
}
}, [internshipType, regForm.getValues()]);
+ // !TEMP -- disable ateneo
+ const universityOptions = refs.universities?.filter((u) => u.name !== "ADMU");
+
return (
@@ -295,7 +298,7 @@ export default function RegisterPage() {
regForm.setValue("university", value + "")
}
diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx
index 887c282c..f229fc52 100644
--- a/app/student/search/[job_id]/page.tsx
+++ b/app/student/search/[job_id]/page.tsx
@@ -27,6 +27,7 @@ import { ApplyConfirmModal } from "@/components/modals/ApplyConfirmModal";
import { applyToJob } from "@/lib/application";
import { useApplicationActions } from "@/lib/api/student.actions.api";
import { ShareJobButton } from "@/components/features/student/job/share-job-button";
+import { useAuthContext } from "@/lib/ctx-auth";
/**
* The individual job page.
@@ -44,6 +45,7 @@ export default function JobPage() {
const profile = useProfileData();
const { universities } = useDbRefs();
+ const { isAuthenticated } = useAuthContext();
const goProfile = useCallback(() => {
applyConfirmModalRef.current?.close();
@@ -104,7 +106,7 @@ export default function JobPage() {
{job.data && (
-
+
@@ -134,7 +136,8 @@ export default function JobPage() {
portfolio_link: profile.data?.portfolio_link ?? null,
}}
job={job.data}
- />
+ isAuthenticated={isAuthenticated()}
+ />
diff --git a/app/student/search/page.tsx b/app/student/search/page.tsx
index b6d995c7..1ad3bae7 100644
--- a/app/student/search/page.tsx
+++ b/app/student/search/page.tsx
@@ -189,11 +189,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 =
@@ -597,6 +593,7 @@ export default function SearchPage() {
openAppModal={() => applyConfirmModalRef.current?.open()}
/>,
]}
+ isAuthenticated={isAuthenticated()}
/>
) : (
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/features/hire/dashboard/JobHeader.tsx b/components/features/hire/dashboard/JobHeader.tsx
index 36db490f..7dd85bd0 100644
--- a/components/features/hire/dashboard/JobHeader.tsx
+++ b/components/features/hire/dashboard/JobHeader.tsx
@@ -112,7 +112,7 @@ export default function JobHeader({
-
+
}
{(job.salary !== undefined && job.allowance === 0) ?
- ₱{job.salary}/{to_job_pay_freq_name(job.salary_freq)} :
+ {formatCurrency(job.salary!)}/{to_job_pay_freq_name(job.salary_freq)} :
}
diff --git a/components/features/hire/dashboard/JobsContent.tsx b/components/features/hire/dashboard/JobsContent.tsx
index a1cbfd06..aa59a049 100644
--- a/components/features/hire/dashboard/JobsContent.tsx
+++ b/components/features/hire/dashboard/JobsContent.tsx
@@ -70,7 +70,7 @@ export function JobsContent({
animate={{ scale: 1, filter: "blur(0px)", opacity: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
- {sortedJobs && sortedJobs.length > 0
+ {sortedJobs && sortedJobs.length > 0 && !showLoader && !isLoading
? (
{
diff --git a/components/features/hire/god/RegisterEmployerModal.tsx b/components/features/hire/god/RegisterEmployerModal.tsx
index e9576b37..d6d2bc7f 100644
--- a/components/features/hire/god/RegisterEmployerModal.tsx
+++ b/components/features/hire/god/RegisterEmployerModal.tsx
@@ -17,7 +17,6 @@ import { Autocomplete } from "@/components/ui/autocomplete";
export type ModalRendererProps = {
children?: React.ReactNode;
className?: string;
- backdropClassName?: string;
};
export function RegisterEmployerButton({ onOpen }: { onOpen: () => void }) {
diff --git a/components/features/hire/header.tsx b/components/features/hire/header.tsx
index a2e27579..012fbf63 100644
--- a/components/features/hire/header.tsx
+++ b/components/features/hire/header.tsx
@@ -5,33 +5,23 @@ import { useAuthContext } from "@/app/hire/authctx";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import {
LogOut,
- Building,
- UserPlus,
XIcon,
- MessageCircleMore,
ChevronRight,
- Search,
- Settings,
LayoutDashboard,
Menu,
- FileUser,
Plus,
HelpCircle,
} from "lucide-react";
-import { useAppContext } from "@/lib/ctx-app";
import { DropdownOption, GroupableNavDropdown } from "@/components/ui/dropdown";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { HeaderTitle } from "@/components/shared/header";
import { useRoute } from "@/hooks/use-route";
import Link from "next/link";
-import { getFullName } from "@/lib/profile";
import { MyEmployerPfp } from "@/components/shared/pfp";
import { useProfile } from "@/hooks/use-employer-api";
import { useMobile } from "@/hooks/use-mobile";
import { useConversations } from "@/hooks/use-conversation";
-import { useGlobalModal } from "@/components/providers/ModalProvider";
-import { useQueryClient } from "@tanstack/react-query";
import { MyUserPfp } from "@/components/shared/pfp";
import { Separator } from "@/components/ui/separator";
diff --git a/components/features/hire/listings/createJob.tsx b/components/features/hire/listings/createJob.tsx
index 4523f341..5dcb2b09 100644
--- a/components/features/hire/listings/createJob.tsx
+++ b/components/features/hire/listings/createJob.tsx
@@ -42,13 +42,13 @@ const CreateJobPage = ({ createJob }: CreateJobPageProps) => {
const [creating, set_creating] = useState(false);
const [isMissing, setMissing] = useState(false);
const { formData, setField, fieldSetter } = useFormData();
- const { job_pay_freq } = useDbRefs();
+ const { job_pay_freq, isNotNull } = useDbRefs();
const router = useRouter();
const profile = useProfile();
const { isMobile } = useMobile();
const isSalaryFilled = typeof formData.salary === "number" && formData.salary;
- const payFreqMissing = isSalaryFilled && !formData.salary_freq;
+ const payFreqMissing = isSalaryFilled && !isNotNull(formData.salary_freq);
const { job_categories } = useDbRefs();
@@ -128,7 +128,7 @@ const CreateJobPage = ({ createJob }: CreateJobPageProps) => {
missingFields.push("Category");
}
- if (isSalaryFilled && !formData.salary_freq) {
+ if (isSalaryFilled && formData.salary_freq === null && formData.salary_freq === undefined) {
missingFields.push("Pay Frequency");
}
@@ -170,7 +170,7 @@ const CreateJobPage = ({ createJob }: CreateJobPageProps) => {
}, []);
useEffect(() => {
- const missing =
+ const missing = !!(
!formData.title?.trim() ||
!formData.location?.trim() ||
!formData.description?.trim() ||
@@ -180,7 +180,8 @@ const CreateJobPage = ({ createJob }: CreateJobPageProps) => {
!formData.internship_preferences?.job_commitment_ids?.length ||
!formData.internship_preferences?.job_setup_ids?.length ||
!formData.internship_preferences?.job_category_ids?.length ||
- payFreqMissing;
+ payFreqMissing
+ );
setMissing(missing);
}, [
diff --git a/components/features/hire/listings/editJob.tsx b/components/features/hire/listings/editJob.tsx
index 60427c17..1ce9f2e3 100644
--- a/components/features/hire/listings/editJob.tsx
+++ b/components/features/hire/listings/editJob.tsx
@@ -25,6 +25,7 @@ import { useMobile } from "@/hooks/use-mobile";
import { useModal } from "@/hooks/use-modal";
import { TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
+import { editorRootElementRef$ } from "@mdxeditor/editor";
interface EditJobPageProps {
job: Job;
@@ -735,7 +736,9 @@ const EditJobPage = ({
tasks, projects, or roles in your company
-
+
{
const router = useRouter();
const { isMobile } = useAppContext();
+ const { isAuthenticated } = useAuthContext();
const [exitingBack, setExitingBack] = useState(false);
const handleBack = () => {
@@ -40,6 +42,7 @@ const JobDetailsPage = ({
diff --git a/components/features/hire/listings/listings-details-panel.tsx b/components/features/hire/listings/listings-details-panel.tsx
index f2c96e5f..165256c3 100644
--- a/components/features/hire/listings/listings-details-panel.tsx
+++ b/components/features/hire/listings/listings-details-panel.tsx
@@ -1,3 +1,4 @@
+import { useAuthContext } from "@/app/hire/authctx";
import { JobDetails } from "@/components/shared/jobs";
import { Button } from "@/components/ui/button";
import { Job } from "@/lib/db/db.types";
@@ -28,6 +29,8 @@ export function ListingsDetailsPanel({
updateJob,
setIsEditing,
}: ListingsDetailsPanelProps) {
+ const { isAuthenticated } = useAuthContext();
+
if (!selectedJob?.id) {
return (
@@ -58,6 +61,7 @@ export function ListingsDetailsPanel({
job={selectedJob}
// @ts-ignore
update_job={updateJob}
+ isAuthenticated={isAuthenticated()}
actions={
isEditing
? [
diff --git a/components/features/student/forms/FormActionButtons.tsx b/components/features/student/forms/FormActionButtons.tsx
deleted file mode 100644
index 98363a1f..00000000
--- a/components/features/student/forms/FormActionButtons.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-"use client";
-
-import useModalRegistry from "@/components/modals/modal-registry";
-import { Button } from "@/components/ui/button";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useFormRendererContext } from "./form-renderer.ctx";
-import { useState } from "react";
-import { useFormFiller } from "./form-filler.ctx";
-import { useMyAutofillUpdate, useMyAutofill } from "@/hooks/use-my-autofill";
-import { useProfileData } from "@/lib/api/student.data.api";
-import { FormService } from "@/lib/api/services";
-import { TextLoader } from "@/components/ui/loader";
-import { FormValues } from "@betterinternship/core/forms";
-import { useQueryClient } from "@tanstack/react-query";
-import { getClientAudit } from "@/lib/audit";
-import { toast } from "sonner";
-import { toastPresets } from "@/components/ui/sonner-toast";
-import { useSignContext } from "@/components/providers/sign.ctx";
-import { CircleHelp } from "lucide-react";
-
-export function FormActionButtons() {
- const form = useFormRendererContext();
- const formFiller = useFormFiller();
- const autofillValues = useMyAutofill();
- const profile = useProfileData();
- const modalRegistry = useModalRegistry();
- const updateAutofill = useMyAutofillUpdate();
- const signContext = useSignContext();
- const queryClient = useQueryClient();
-
- const noEsign = !form.formMetadata.mayInvolveEsign();
- const initiateFormLabel = "Sign via BetterInternship";
- const filloutFormLabel = !noEsign ? "Print for Wet Signature" : "Print form";
-
- const [busy, setBusy] = useState
(false);
- const onWithoutEsignClick = () => void handleSubmit(false);
- const onWithEsignClick = () => void handleSubmit(true);
-
- /**
- * This submits the form to the server
- * @param withEsign - if true, enables e-sign; if false, does prefill
- * @param _bypassConfirm - internal flag to skip recipient confirmation on re-call
- * @returns
- */
- const handleSubmit = async (withEsign?: boolean) => {
- setBusy(true);
- if (!profile.data?.id) return;
-
- // Validate fields before allowing to proceed
- const finalValues = formFiller.getFinalValues(autofillValues);
- const errors = formFiller.validate(form.fields, autofillValues);
-
- if (Object.keys(errors).length) {
- toast.error(
- "Some information is missing or incorrect",
- toastPresets.destructive,
- );
- setBusy(false);
- return;
- }
-
- // proceed to save + submit
- try {
- setBusy(true);
-
- // Update autofill afterwards (so even if it fails, autofill is there)
- await updateAutofill(form.formName, form.fields, finalValues);
-
- // Iniate e-sign
- if (withEsign) {
- // Check if other parties need to be requested from
- const signingPartyBlocks =
- form.formMetadata.getSigningPartyBlocks("initiator");
-
- // Open request for contacts
- if (signingPartyBlocks.length) {
- modalRegistry.specifySigningParties.open(
- form.fields,
- formFiller,
- signingPartyBlocks,
- (signingPartyValues: FormValues) =>
- FormService.initiateForm({
- formName: form.formName,
- formVersion: form.formVersion,
- values: { ...finalValues, ...signingPartyValues },
- audit: getClientAudit(),
- }),
- autofillValues,
- form.formMetadata.getSigningParties(),
- );
-
- // Just e-sign and fill-out right away
- } else {
- const response = await FormService.initiateForm({
- formName: form.formName,
- formVersion: form.formVersion,
- values: finalValues,
- audit: getClientAudit(),
- });
-
- if (!response.success) {
- setBusy(false);
- alert("Something went wrong, please try again.");
- console.error(response.message);
- return;
- }
-
- await queryClient.invalidateQueries({ queryKey: ["my-forms"] });
- modalRegistry.formSubmissionSuccess.open("esign");
- }
-
- // Just fill out form
- } else {
- const response = await FormService.filloutForm({
- formName: form.formName,
- formVersion: form.formVersion,
- values: finalValues,
- });
-
- if (!response.success) {
- setBusy(false);
- alert("Something went wrong, please try again.");
- console.error(response.message);
- return;
- }
-
- await queryClient.invalidateQueries({ queryKey: ["my-forms"] });
-
- modalRegistry.formSubmissionSuccess.open("manual");
- }
-
- setBusy(false);
- } catch (e) {
- console.error("Submission error", e);
- } finally {
- setBusy(false);
- }
- };
-
- return (
-
-
- {noEsign ? (
-
-
-
- Fill out
- {filloutFormLabel}
-
-
-
- ) : (
-
-
-
-
-
- Manual
- {filloutFormLabel}
-
-
-
-
-
-
- You’ll complete the form and sign it by hand after printing.
-
-
- )}
-
- {!noEsign && (
-
-
-
-
-
- E-Sign
-
- {initiateFormLabel}
-
-
-
-
-
-
-
- Start an online signing process through BetterIntership. We’ll
- email all required parties and let you track progress, 10× faster.
-
-
- )}
-
-
- );
-}
diff --git a/components/features/student/forms/FormFillerRenderer.tsx b/components/features/student/forms/FormFillerRenderer.tsx
index 3121b258..0112ca01 100644
--- a/components/features/student/forms/FormFillerRenderer.tsx
+++ b/components/features/student/forms/FormFillerRenderer.tsx
@@ -5,7 +5,6 @@ import { ClientBlock } from "@betterinternship/core/forms";
import { FieldRenderer } from "./FieldRenderer";
import { HeaderRenderer, ParagraphRenderer } from "./BlockRenderer";
import { useFormRendererContext } from "./form-renderer.ctx";
-import { FormActionButtons } from "./FormActionButtons";
import { getBlockField, isBlockField } from "./utils";
import { useFormFiller } from "./form-filler.ctx";
import { useMyAutofill } from "@/hooks/use-my-autofill";
@@ -16,8 +15,14 @@ import { formatTimestampDateWithoutTime } from "@/lib/utils";
export function FormFillerRenderer({
onValuesChange,
+ onFieldSelect,
+ selectionTick = 0,
+ autoScrollToSelectedField = true,
}: {
onValuesChange?: (values: Record) => void;
+ onFieldSelect?: (fieldId: string) => void;
+ selectionTick?: number;
+ autoScrollToSelectedField?: boolean;
}) {
const form = useFormRendererContext();
const formFiller = useFormFiller();
@@ -141,15 +146,27 @@ export function FormFillerRenderer({
// Scroll to selected field
useEffect(() => {
- if (!form.selectedPreviewId || !fieldRefs.current[form.selectedPreviewId])
+ if (
+ !autoScrollToSelectedField ||
+ !form.selectedPreviewId ||
+ !fieldRefs.current[form.selectedPreviewId] ||
+ !scrollContainerRef.current
+ )
return;
const fieldElement = fieldRefs.current[form.selectedPreviewId];
const scrollContainer = scrollContainerRef.current;
if (fieldElement && scrollContainer) {
- // Scroll the field into view with a small padding
- fieldElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const fieldRect = fieldElement.getBoundingClientRect();
+ const isVisible =
+ fieldRect.top >= containerRect.top &&
+ fieldRect.bottom <= containerRect.bottom;
+
+ if (!isVisible) {
+ fieldElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
// Add a highlight animation
fieldElement.classList.add(
@@ -167,7 +184,7 @@ export function FormFillerRenderer({
);
}, 1500);
}
- }, [form.selectedPreviewId]);
+ }, [autoScrollToSelectedField, form.selectedPreviewId, selectionTick]);
// Scroll to first field with error
useEffect(() => {
@@ -212,9 +229,6 @@ export function FormFillerRenderer({
ref={scrollContainerRef}
className="relative flex-1 overflow-auto flex flex-col"
>
-
-
{form.formLabel}
-
{
+ setSelected={(fieldId) => {
+ if (onFieldSelect) {
+ onFieldSelect(fieldId);
+ return;
+ }
+ form.setSelectedPreviewId(fieldId);
+ }}
+ onBlurValidate={(fieldKey) => {
// Before validating, sync form values to params so validators can access them
const currentValues = formFiller.getFinalValues(autofillValues);
@@ -256,9 +276,6 @@ export function FormFillerRenderer({
/>
-
-
-
);
}
@@ -280,7 +297,7 @@ const BlocksRenderer =
({
onChange: (key: string, value: any) => void;
errors: Record;
setSelected: (selected: string) => void;
- onBlurValidate?: (fieldKey: string, field: any) => void;
+ onBlurValidate?: (fieldKey: string) => void;
fieldRefs: Record;
selectedFieldId?: string;
}) => {
@@ -323,9 +340,7 @@ const BlocksRenderer = ({
field={actualField}
value={values[actualField.field]}
onChange={(v) => onChange(actualField.field, v)}
- onBlur={() =>
- onBlurValidate?.(actualField.field, actualField)
- }
+ onBlur={() => onBlurValidate?.(actualField.field)}
error={errors[actualField.field]}
allValues={values}
isPhantom={isPhantom}
diff --git a/components/features/student/forms/FormFlowRouter.tsx b/components/features/student/forms/FormFlowRouter.tsx
deleted file mode 100644
index 0b6141d5..00000000
--- a/components/features/student/forms/FormFlowRouter.tsx
+++ /dev/null
@@ -1,218 +0,0 @@
-"use client";
-
-import { useEffect, useState, useMemo } from "react";
-import { FormFillerRenderer } from "./FormFillerRenderer";
-import { Loader } from "@/components/ui/loader";
-import { Button } from "@/components/ui/button";
-import { cn } from "@/lib/utils";
-import { FormPreviewPdfDisplay } from "./previewer";
-import { Loader2, ChevronLeft } from "lucide-react";
-import { useFormRendererContext } from "./form-renderer.ctx";
-import { useFormFiller } from "./form-filler.ctx";
-import { useMyAutofill } from "@/hooks/use-my-autofill";
-import { getBlockField, isBlockField } from "./utils";
-import { FormActionButtons } from "./FormActionButtons";
-import { toast } from "sonner";
-import { toastPresets } from "@/components/ui/sonner-toast";
-
-export function FormAndDocumentLayout({ formName }: { formName: string }) {
- const form = useFormRendererContext();
- const formFiller = useFormFiller();
- const autofillValues = useMyAutofill();
- const [mobileStage, setMobileStage] = useState<"preview" | "form" | "sign">(
- "preview",
- );
- const [values, setValues] = useState>({});
-
- // Filter blocks to only include manual source fields (same as FormFillerRenderer)
- const manualBlocks = useMemo(
- () =>
- form.blocks.filter(
- (block) =>
- isBlockField(block) && getBlockField(block)?.source === "manual",
- ),
- [form.blocks],
- );
-
- // Get keyedFields that correspond to manual blocks (for PDF preview with coordinates)
- const manualKeyedFields = useMemo(() => {
- if (!form.keyedFields || form.keyedFields.length === 0) return [];
-
- // Get field names from manual blocks
- const manualFieldNames = new Set(
- manualBlocks.map((block) => getBlockField(block)?.field).filter(Boolean),
- );
-
- // Filter keyedFields to only those in manual blocks
- return form.keyedFields.filter((kf) => manualFieldNames.has(kf.field));
- }, [form.keyedFields, manualBlocks]);
-
- useEffect(() => {
- form.updateFormName(formName);
- }, [formName]);
-
- // Loader
- if (!form.formMetadata || form.loading)
- return Loading form... ;
-
- return (
-
- {/* ============ MOBILE LAYOUT ============ */}
-
- {/* Mobile: Preview Stage - Show PDF */}
- {mobileStage === "preview" && (
-
-
- {form.document.url ? (
-
- form.setSelectedPreviewId(fieldName)
- }
- selectedFieldId={form.selectedPreviewId}
- scale={0.7}
- />
- ) : (
-
- No preview available
-
- )}
-
-
-
- setMobileStage("form")}
- disabled={form.loading}
- >
- Start Filling
-
-
-
- )}
-
- {/* Mobile: Form Stage - Show Form Only */}
- {mobileStage === "form" && (
-
-
- {form.loading ? (
-
-
-
- Loading form…
-
-
- ) : (
-
- )}
-
-
-
- setMobileStage("preview")}
- >
- Back
-
- {
- // Validate fields before proceeding
- const errors = formFiller.validate(
- form.fields,
- autofillValues,
- );
- if (Object.keys(errors).length > 0) {
- toast.error(
- "There are missing fields",
- toastPresets.destructive,
- );
- return;
- }
- setMobileStage("sign");
- }}
- >
- Review
-
-
-
- )}
-
- {/* Mobile: Sign Stage - Show PDF with Signing Options */}
- {mobileStage === "sign" && (
-
-
- {form.document.url ? (
-
- form.setSelectedPreviewId(fieldName)
- }
- selectedFieldId={form.selectedPreviewId}
- scale={0.7}
- />
- ) : (
-
- No preview available
-
- )}
-
-
-
-
setMobileStage("form")}
- >
-
-
-
-
-
-
-
- )}
-
-
- {/* ============ DESKTOP LAYOUT ============ */}
-
- {/* Desktop: Form on Left */}
-
- {form.loading ? (
-
-
-
- Loading form…
-
-
- ) : (
-
- )}
-
-
- {/* Desktop: PDF Preview on Right */}
-
- {!form.loading && form.document.url ? (
-
-
- form.setSelectedPreviewId(fieldName)
- }
- selectedFieldId={form.selectedPreviewId}
- />
-
- ) : null}
-
-
-
- );
-}
diff --git a/components/features/student/forms/FormGenerateCard.tsx b/components/features/student/forms/FormGenerateCard.tsx
deleted file mode 100644
index a05919bf..00000000
--- a/components/features/student/forms/FormGenerateCard.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-"use client";
-
-import { Card } from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { SquareArrowOutUpRight } from "lucide-react";
-import { useRouter } from "next/navigation";
-import useModalRegistry from "@/components/modals/modal-registry";
-import { useMyForms } from "@/app/student/forms/myforms.ctx";
-
-export default function FormTemplateCard({
- formLabel,
- onGenerate,
-}: {
- formLabel: string;
- onGenerate: () => void;
-}) {
- const modalRegistry = useModalRegistry();
- const { shouldShowDuplicateWarning } = useMyForms();
-
- const handleClick = () => {
- const warningResult = shouldShowDuplicateWarning(formLabel);
-
- // If no warning needed, proceed
- if (!warningResult.show) {
- onGenerate();
- return;
- }
-
- // Show warning modal
- if (modalRegistry?.duplicateFormWarning) {
- modalRegistry.duplicateFormWarning.open({
- formLabel,
- hasPendingInstance: warningResult.hasPending,
- hasCompletedInstance: warningResult.hasCompleted,
- onGenerateAnother: onGenerate,
- onGoBack: () => {
- // Modal closes automatically
- },
- });
- } else {
- // Fallback if modal not available
- onGenerate();
- }
- };
-
- return (
-
- );
-}
diff --git a/components/features/student/forms/form-filler.ctx.tsx b/components/features/student/forms/form-filler.ctx.tsx
index f4465568..256f30b4 100644
--- a/components/features/student/forms/form-filler.ctx.tsx
+++ b/components/features/student/forms/form-filler.ctx.tsx
@@ -32,6 +32,20 @@ const FormFillerContext = createContext({} as IFormFiller);
export const useFormFiller = () => useContext(FormFillerContext);
+export const FormFillerContextBridge = ({
+ value,
+ children,
+}: {
+ value: IFormFiller;
+ children: React.ReactNode;
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
export const FormFillerContextProvider = ({
children,
}: {
diff --git a/components/features/student/forms/form-renderer.ctx.tsx b/components/features/student/forms/form-renderer.ctx.tsx
index 3960fe80..412c396e 100644
--- a/components/features/student/forms/form-renderer.ctx.tsx
+++ b/components/features/student/forms/form-renderer.ctx.tsx
@@ -1,7 +1,7 @@
/**
* @ Author: BetterInternship
* @ Create Time: 2025-11-09 03:19:04
- * @ Modified time: 2026-01-06 19:31:46
+ * @ Modified time: 2026-02-19 01:22:55
* @ Description:
*
* We can move this out later on so it becomes reusable in other places.
@@ -71,6 +71,20 @@ const FormRendererContext = createContext>(
);
export const useFormRendererContext = () => useContext(FormRendererContext);
+export const FormRendererContextBridge = ({
+ value,
+ children,
+}: {
+ value: IFormRendererContext<[PublicUser]>;
+ children: React.ReactNode;
+}) => {
+ return (
+
+ {children}
+
+ );
+};
+
/**
* Gives access to form context api
*
@@ -162,14 +176,14 @@ export const FormRendererContextProvider = ({
const fm = new FormMetadata(form.formMetadata);
const newFormName = form.formMetadata.name;
const newFormLabel = form.formMetadata.label;
- const newFormVersion = form.formDocument.version;
+ const newFormVersion = form.formTemplate.version;
// Only update form if it's new
setFormMetadata(fm);
setFormName(newFormName);
setFormLabel(newFormLabel);
setFormVersion(newFormVersion);
- setDocumentName(form.formDocument.name);
+ setDocumentName(form.formTemplate.name);
setDocumentUrl(form.documentUrl);
const loadedFields = fm.getFieldsForClientService("initiator");
diff --git a/components/features/student/forms/previewer.tsx b/components/features/student/forms/previewer.tsx
index f31e9aa1..5207a066 100644
--- a/components/features/student/forms/previewer.tsx
+++ b/components/features/student/forms/previewer.tsx
@@ -6,312 +6,44 @@ import {
getDocument,
version as pdfjsVersion,
} from "pdfjs-dist";
+import { type IFormSigningParty } from "@betterinternship/core/forms";
import type {
PDFDocumentProxy,
PDFPageProxy,
RenderTask,
} from "pdfjs-dist/types/src/display/api";
import type { PageViewport } from "pdfjs-dist/types/src/display/display_utils";
-import { type IFormBlock } from "@betterinternship/core/forms";
import { Loader } from "@/components/ui/loader";
import { ZoomIn, ZoomOut } from "lucide-react";
-
-// Load Roboto font from Google Fonts and wait for it to load
-if (typeof window !== "undefined") {
- const link = document.createElement("link");
- link.href =
- "https://fonts.googleapis.com/css2?family=Roboto:wght@400&family=Italianno&display=block";
- link.rel = "stylesheet";
- document.head.appendChild(link);
-
- // Ensure fonts are loaded before using them
- if ("fonts" in document) {
- document.fonts.ready.catch(() => {
- // Font loading failed, but continue anyway
- });
- }
-}
-
-// Text wrapping and fitting utilities (matches PDF engine exactly)
-
-// Shared canvas for text measurements (optimization to avoid creating new canvases)
-let sharedCanvas: HTMLCanvasElement | null = null;
-let sharedCtx: CanvasRenderingContext2D | null = null;
-
-// Cache for fitNoWrap results to avoid recalculating (optimization for signatures/repeated fields)
-const fitNoWrapCache = new Map<
- string,
- {
- fontSize: number;
- line: string;
- ascent: number;
- descent: number;
- height: number;
- }
->();
-
-function getSharedContext(): CanvasRenderingContext2D | null {
- if (!sharedCanvas) {
- sharedCanvas = document.createElement("canvas");
- sharedCtx = sharedCanvas.getContext("2d");
- }
- return sharedCtx;
-}
-
-// Measure text width using Canvas (used by wrapText)
-function measureTextWidth(text: string, fontSize: number): number {
- const ctx = getSharedContext();
- if (!ctx) return 0;
- ctx.font = `${fontSize}px Roboto`;
- return ctx.measureText(text).width;
-}
-
-// Get font metrics (approximated for browser, matching PDF engine approach)
-function getFontMetricsAtSize(fontSize: number) {
- const ascent = fontSize * 0.8;
- const descent = -fontSize * 0.26;
- return {
- ascent,
- descent,
- height: ascent - descent,
- };
-}
-
-// Wrap text into lines that fit maxWidth (matches PDF engine)
-function wrapText({
- text,
- fontSize,
- maxWidth,
- zoom = 1,
-}: {
- text: string;
- fontSize: number;
- maxWidth: number;
- zoom?: number;
-}): string[] {
- const paragraphs = String(text ?? "").split(/\r?\n/);
- const lines: string[] = [];
- const measure = (s: string) => measureTextWidth(s, fontSize) * zoom;
-
- const breakLongWord = (word: string): string[] => {
- const parts: string[] = [];
- let cur = "";
- for (const ch of word) {
- const next = cur + ch;
- if (cur && measure(next) > maxWidth) {
- parts.push(cur);
- cur = ch;
- } else {
- cur = next;
- }
- }
- if (cur) parts.push(cur);
- return parts;
- };
-
- for (const para of paragraphs) {
- const trimmed = para.trim();
- if (!trimmed) {
- lines.push("");
- continue;
- }
-
- const words = trimmed.split(/\s+/);
- let current = "";
-
- for (const w of words) {
- const candidate = current ? `${current} ${w}` : w;
-
- if (measure(candidate) <= maxWidth) {
- current = candidate;
- continue;
- }
-
- if (current) {
- lines.push(current);
- current = "";
- }
-
- if (measure(w) <= maxWidth) {
- current = w;
- } else {
- const broken = breakLongWord(w);
- for (let i = 0; i < broken.length; i++) {
- if (i === broken.length - 1) current = broken[i];
- else lines.push(broken[i]);
- }
- }
- }
-
- if (current) lines.push(current);
- }
-
- return lines;
-}
-
-function layoutWrappedBlock({
- text,
- fontSize,
- lineHeight,
- maxWidth,
- zoom = 1,
-}: {
- text: string;
- fontSize: number;
- lineHeight: number;
- maxWidth: number;
- zoom?: number;
-}) {
- const { ascent, descent } = getFontMetricsAtSize(fontSize);
- const lines = wrapText({ text, fontSize, maxWidth, zoom });
- const n = lines.length;
- const blockHeight = (n > 0 ? (n - 1) * lineHeight : 0) + (ascent - descent);
- return { lines, ascent, descent, blockHeight };
-}
-
-function fitWrapped({
- text,
- maxWidth,
- maxHeight,
- startSize,
- lineHeightMult = 1.2,
- zoom = 1,
-}: {
- text: string;
- maxWidth: number;
- maxHeight: number;
- startSize: number;
- lineHeightMult?: number;
- zoom?: number;
-}) {
- const fits = (size: number): boolean => {
- const lh = size * lineHeightMult;
- const { blockHeight } = layoutWrappedBlock({
- text,
- fontSize: size,
- lineHeight: lh,
- maxWidth,
- zoom,
- });
- return blockHeight <= maxHeight + 1e-6;
- };
-
- if (fits(startSize)) {
- const lh = startSize * lineHeightMult;
- const laid = layoutWrappedBlock({
- text,
- fontSize: startSize,
- lineHeight: lh,
- maxWidth,
- zoom,
- });
- return { fontSize: startSize, lineHeight: lh, ...laid };
- }
-
- let hi = startSize;
- let lo = startSize;
- while (!fits(lo)) {
- lo /= 2;
- if (lo < 0.1) break;
- }
-
- for (let i = 0; i < 22; i++) {
- const mid = (lo + hi) / 2;
- if (fits(mid)) lo = mid;
- else hi = mid;
- }
-
- const bestSize = lo;
- const bestLineHeight = bestSize * lineHeightMult;
- const laid = layoutWrappedBlock({
- text,
- fontSize: bestSize,
- lineHeight: bestLineHeight,
- maxWidth,
- zoom,
- });
- return { fontSize: bestSize, lineHeight: bestLineHeight, ...laid };
-}
-
-// For non-wrapping fields: find the largest font size that fits text on ONE line
-function fitNoWrap({
- text,
- maxWidth,
- maxHeight,
- startSize,
-}: {
- text: string;
- maxWidth: number;
- maxHeight: number;
- startSize: number;
-}) {
- const line = String(text ?? "").replace(/\r?\n/g, " ");
-
- // Create cache key from inputs - cache results to avoid recalculating
- const cacheKey = `${line}|${maxWidth}|${maxHeight}|${startSize}`;
- if (fitNoWrapCache.has(cacheKey)) {
- return fitNoWrapCache.get(cacheKey)!;
- }
-
- // TWEAK THIS VALUE: Higher = more aggressive shrinking (more safety margin)
- // Try: 2 (minimal), 4 (conservative), 8 (moderate), 12 (aggressive)
- const SAFETY_MARGIN = 0;
-
- // Use shared canvas context (optimization to avoid creating new canvases)
- const ctx = getSharedContext();
-
- const fits = (size: number): boolean => {
- if (!ctx) return false;
- ctx.font = `${size}px Roboto`;
- // Measure text width with a small correction factor for safety
- const w = ctx.measureText(line).width * 0.7;
- const { height } = getFontMetricsAtSize(size);
- return w <= maxWidth - SAFETY_MARGIN && height <= maxHeight - SAFETY_MARGIN;
- };
-
- // If startSize already fits, return it
- if (fits(startSize)) {
- const { ascent, descent, height } = getFontMetricsAtSize(startSize);
- const result = { fontSize: startSize, line, ascent, descent, height };
- fitNoWrapCache.set(cacheKey, result);
- return result;
- }
-
- // Binary search down to find a size that fits
- let lo = startSize;
- while (!fits(lo)) {
- lo /= 2;
- if (lo < 0.1) break;
- }
-
- // Binary search up to find the largest size that fits
- let hi = startSize;
- for (let i = 0; i < 22; i++) {
- const mid = (lo + hi) / 2;
- if (fits(mid)) {
- lo = mid;
- } else {
- hi = mid;
- }
- }
-
- const bestSize = lo;
- const { ascent, descent, height } = getFontMetricsAtSize(bestSize);
- const result = { fontSize: bestSize, line, ascent, descent, height };
-
- // Store in cache for future calls
- fitNoWrapCache.set(cacheKey, result);
-
- return result;
-}
-
+import { useDbRefs } from "@/lib/db/use-refs";
+import { useProfileData } from "@/lib/api/student.data.api";
+import { useAppContext } from "@/lib/ctx-app";
+import {
+ createPreviewDisplayValueResolver,
+ groupFieldsByPage,
+ normalizePreviewFieldKey,
+ toPreviewFields,
+ type PreviewField,
+ type PreviewFieldInput,
+} from "@/lib/form-previewer-model";
+import {
+ ensurePreviewFontsLoaded,
+ fitNoWrapText,
+ fitWrappedText,
+ resolvePreviewFont,
+} from "@/lib/form-previewer-rendering";
interface FormPreviewPdfDisplayProps {
documentUrl: string;
- blocks: any[]; // ServerField[] with coordinates (x, y, w, h, page, field)
+ blocks?: PreviewFieldInput[];
values: Record;
+ headerLeft?: React.ReactNode;
scale?: number;
onFieldClick?: (fieldName: string) => void;
selectedFieldId?: string;
+ selectionTick?: number;
+ autoScrollToSelectedField?: boolean;
+ fieldErrors?: Record;
+ signingParties?: IFormSigningParty[];
}
const clamp = (value: number, min: number, max: number) =>
@@ -326,38 +58,114 @@ export const FormPreviewPdfDisplay = ({
documentUrl,
blocks,
values,
- scale: initialScale = 1.0,
+ headerLeft,
+ scale: initialScale,
onFieldClick,
selectedFieldId,
+ selectionTick = 0,
+ autoScrollToSelectedField = true,
+ fieldErrors = {},
+ signingParties = [],
}: FormPreviewPdfDisplayProps) => {
+ const { isMobile } = useAppContext();
+ const refs = useDbRefs();
+ const profile = useProfileData();
+ const defaultScale = isMobile ? 0.5 : 0.9;
const [pdfDoc, setPdfDoc] = useState(null);
const [pageCount, setPageCount] = useState(0);
- const [scale, setScale] = useState(initialScale);
+ const [scale, setScale] = useState(initialScale ?? defaultScale);
const [visiblePage, setVisiblePage] = useState(1);
- const [selectedPage, setSelectedPage] = useState(1);
const [isLoadingDoc, setIsLoadingDoc] = useState(false);
const [error, setError] = useState(null);
const [animatingFieldId, setAnimatingFieldId] = useState(null);
const pageRefs = useRef>(new Map());
+ const fieldRefs = useRef>(new Map());
+ const scrollContainerRef = useRef(null);
+ const normalizedFields = useMemo(
+ () => toPreviewFields(blocks ?? []),
+ [blocks],
+ );
+ const fieldsByPage = useMemo(
+ () => groupFieldsByPage(normalizedFields),
+ [normalizedFields],
+ );
+ const signingPartyLabelById = useMemo(() => {
+ const partyLabelById = new Map();
+ signingParties.forEach((party) => {
+ partyLabelById.set(party._id, party.signatory_title || party._id);
+ });
+ return partyLabelById;
+ }, [signingParties]);
+
+ const resolveDisplayValue = useMemo(
+ () =>
+ createPreviewDisplayValueResolver({
+ refs,
+ user: profile.data as Record | null,
+ }),
+ [refs, profile.data],
+ );
+ const registerFieldRef = useCallback(
+ (fieldName: string, node: HTMLDivElement | null) => {
+ if (!node) {
+ fieldRefs.current.delete(fieldName);
+ return;
+ }
+ fieldRefs.current.set(fieldName, node);
+ },
+ [],
+ );
+
+ // Re-apply default zoom when a new document is opened.
+ useEffect(() => {
+ setScale(initialScale ?? defaultScale);
+ }, [documentUrl, initialScale, defaultScale]);
// Jump to field's page and trigger animation when selected from form
useEffect(() => {
if (!selectedFieldId) return;
- const selectedField = blocks.find((b) => b.field === selectedFieldId);
- if (selectedField && selectedField.page) {
- const fieldPage = selectedField.page;
- setSelectedPage(fieldPage);
- const pageNode = pageRefs.current.get(fieldPage);
- pageNode?.scrollIntoView({ behavior: "smooth", block: "center" });
+ if (autoScrollToSelectedField) {
+ const selectedFieldNode = fieldRefs.current.get(selectedFieldId);
+ const scrollContainer = scrollContainerRef.current;
+
+ if (selectedFieldNode && scrollContainer) {
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const fieldRect = selectedFieldNode.getBoundingClientRect();
+ const isVisible =
+ fieldRect.top >= containerRect.top &&
+ fieldRect.bottom <= containerRect.bottom;
+
+ if (!isVisible) {
+ selectedFieldNode.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ inline: "nearest",
+ });
+ }
+ } else {
+ const selectedField = normalizedFields.find(
+ (field) => field.field === selectedFieldId,
+ );
+ if (selectedField && selectedField.page) {
+ const fieldPage = selectedField.page;
+ const pageNode = pageRefs.current.get(fieldPage);
+ pageNode?.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+ }
}
// Trigger bump animation
setAnimatingFieldId(selectedFieldId);
const timeout = setTimeout(() => setAnimatingFieldId(null), 600);
return () => clearTimeout(timeout);
- }, [selectedFieldId, blocks]);
+ }, [
+ selectedFieldId,
+ selectionTick,
+ normalizedFields,
+ autoScrollToSelectedField,
+ ]);
// Initialize PDF.js worker
useEffect(() => {
@@ -368,6 +176,10 @@ export const FormPreviewPdfDisplay = ({
GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsVersion}/${workerFile}`;
}, []);
+ useEffect(() => {
+ ensurePreviewFontsLoaded();
+ }, []);
+
// Load PDF document
useEffect(() => {
if (!documentUrl) return;
@@ -386,7 +198,13 @@ export const FormPreviewPdfDisplay = ({
})
.catch((err) => {
if (!cancelled) {
- setError(err.message || "Failed to load PDF");
+ const message =
+ err && typeof err === "object" && "message" in err
+ ? String(
+ (err as { message?: string }).message || "Failed to load PDF",
+ )
+ : "Failed to load PDF";
+ setError(message);
setPdfDoc(null);
}
})
@@ -396,7 +214,7 @@ export const FormPreviewPdfDisplay = ({
return () => {
cancelled = true;
- loadingTask.destroy();
+ void loadingTask.destroy();
};
}, [documentUrl]);
@@ -412,13 +230,6 @@ export const FormPreviewPdfDisplay = ({
setScale((prev) => clamp(parseFloat((prev + delta).toFixed(2)), 0.5, 3));
};
- const handleJumpToPage = (page: number) => {
- if (!page || page < 1 || page > pageCount) return;
- setSelectedPage(page);
- const node = pageRefs.current.get(page);
- node?.scrollIntoView({ behavior: "smooth", block: "start" });
- };
-
const pagesArray = useMemo(
() => Array.from({ length: pageCount }, (_, idx) => idx + 1),
[pageCount],
@@ -446,37 +257,43 @@ export const FormPreviewPdfDisplay = ({
return (
{/* Top Controls */}
-
-
-
-
- Page {visiblePage} of {pageCount}
+
+
+ {headerLeft ?
{headerLeft}
: null}
+
+
+ {visiblePage}/{pageCount}
-
-
-
handleZoom("out")}
- className="rounded p-2 hover:bg-slate-100"
- title="Zoom out"
- >
-
-
-
+
+ handleZoom("out")}
+ className="rounded p-1.5 hover:bg-slate-100"
+ title="Zoom out"
+ aria-label="Zoom out"
+ >
+
+
+ handleZoom("in")}
+ className="rounded p-1.5 hover:bg-slate-100"
+ title="Zoom in"
+ aria-label="Zoom in"
+ >
+
+
+
+
{Math.round(scale * 100)}%
- handleZoom("in")}
- className="rounded p-2 hover:bg-slate-100"
- title="Zoom in"
- >
-
-
{/* Pages container */}
-
+
{pagesArray.map((pageNumber) => (
setVisiblePage(pageNumber)}
registerPageRef={registerPageRef}
- blocks={blocks.filter((b) => {
- // Handle both IFormBlock and ServerField formats
- const page = b.page || b.field_schema?.page;
- return page === pageNumber;
- })}
+ fields={fieldsByPage.get(pageNumber) || []}
values={values}
onFieldClick={onFieldClick}
animatingFieldId={animatingFieldId}
selectedFieldId={selectedFieldId}
+ registerFieldRef={registerFieldRef}
+ fieldErrors={fieldErrors}
+ resolveDisplayValue={resolveDisplayValue}
+ signingPartyLabelById={signingPartyLabelById}
/>
))}
@@ -511,31 +328,47 @@ interface PdfPageWithFieldsProps {
isVisible: boolean;
onVisible: (page: number) => void;
registerPageRef: (page: number, node: HTMLDivElement | null) => void;
- blocks: IFormBlock[];
+ fields: PreviewField[];
values: Record
;
onFieldClick?: (fieldName: string) => void;
animatingFieldId?: string | null;
selectedFieldId?: string;
+ registerFieldRef: (fieldName: string, node: HTMLDivElement | null) => void;
+ fieldErrors: Record;
+ resolveDisplayValue: (field: PreviewField, rawValue: unknown) => string;
+ signingPartyLabelById: Map;
}
const PdfPageWithFields = ({
pdf,
pageNumber,
scale,
- isVisible,
+ isVisible: _isVisible,
onVisible,
registerPageRef,
- blocks,
+ fields,
values,
onFieldClick,
animatingFieldId,
selectedFieldId,
+ registerFieldRef,
+ fieldErrors,
+ resolveDisplayValue,
+ signingPartyLabelById,
}: PdfPageWithFieldsProps) => {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const viewportRef = useRef(null);
const [rendering, setRendering] = useState(false);
const [forceRender, setForceRender] = useState(0);
+ const [hoveredFieldId, setHoveredFieldId] = useState(null);
+ const [activeTouchFieldId, setActiveTouchFieldId] = useState(
+ null,
+ );
+ const [isTouchInteraction, setIsTouchInteraction] = useState(false);
+ const [clickedHighlightFieldId, setClickedHighlightFieldId] = useState<
+ string | null
+ >(null);
// offscreen canvas for text measurement
@@ -549,6 +382,15 @@ const PdfPageWithFields = ({
setForceRender((prev) => prev + 1);
}, [scale]);
+ useEffect(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return;
+ const media = window.matchMedia("(hover: none), (pointer: coarse)");
+ const update = () => setIsTouchInteraction(media.matches);
+ update();
+ media.addEventListener("change", update);
+ return () => media.removeEventListener("change", update);
+ }, []);
+
// Setup intersection observer for visibility
useEffect(() => {
const element = containerRef.current;
@@ -654,16 +496,20 @@ const PdfPageWithFields = ({
{/* Field boxes overlay */}
-
- {blocks.map((block) => {
- const x = block.x || 0;
- const y = block.y || 0;
- const w = block.w || 0;
- const h = block.h || 0;
- const fieldName = block.field;
- const label = block.label || fieldName;
-
- if (!x || !y || !w || !h) {
+
{
+ if (isTouchInteraction) setActiveTouchFieldId(null);
+ }}
+ >
+ {fields.map((field) => {
+ const x = field.x;
+ const y = field.y;
+ const w = field.w;
+ const h = field.h;
+ const fieldName = field.field;
+ if (w <= 0 || h <= 0) {
return null;
}
@@ -672,87 +518,149 @@ const PdfPageWithFields = ({
return null;
}
- const rect = canvasRef.current?.getBoundingClientRect();
- const canvas = canvasRef.current;
- if (!rect || !canvas) return null;
-
- // Canvas internal resolution is scaled by DPR, but CSS size compensates
- // We only need the scale factor, not DPR-adjusted dimensions
const widthPixels = w * scale;
const heightPixels = h * scale;
- const rawValue = values[fieldName];
- // Handle different value types (string, array, object, etc)
- const valueStr = Array.isArray(rawValue)
- ? rawValue.join(", ")
- : typeof rawValue === "string"
- ? rawValue
- : "";
+ const owner = String(field.signing_party_id ?? "").toLowerCase();
+ const hasAssignedOwner = owner.length > 0;
+ const isOwnedByInitiator =
+ !hasAssignedOwner || owner === "initiator" || owner === "student";
+ const normalizedFieldName = normalizePreviewFieldKey(fieldName);
+ const rawValue = isOwnedByInitiator
+ ? (values[fieldName] ??
+ values[normalizedFieldName + ":default"] ??
+ values[normalizedFieldName])
+ : "";
+ const valueStr = isOwnedByInitiator
+ ? resolveDisplayValue(field, rawValue)
+ : "";
const isFilled = valueStr.trim().length > 0;
// Get alignment and wrapping from field schema
- const align_h = block.align_h ?? "left";
- const align_v = block.align_v ?? "top";
- const shouldWrap = block.wrap ?? true;
+ const align_h = field.align_h ?? "left";
+ const align_v = field.align_v ?? "top";
+ const shouldWrap = field.wrap ?? true;
// Calculate optimal font size using PDF engine algorithm
- const fieldType =
- block.field_schema?.type ||
- block.phantom_field_schema?.type ||
- block.type;
+ const fieldType: PreviewField["type"] = field.type ?? "text";
+ const resolvedFont = resolvePreviewFont(fieldType, field.font);
- let fontSize: number;
- let lineHeight: number;
+ let fontSizeDoc: number;
+ let lineHeightDoc: number;
let displayLines: string[] = [];
+ const fitSafetyUnits = 2;
+ const fitMaxWidthDoc = Math.max(0, w - fitSafetyUnits);
+ const fitMaxHeightDoc = Math.max(0, h - fitSafetyUnits);
if (isFilled) {
if (shouldWrap) {
- // Use exact PDF engine algorithm for text with wrapping (no padding)
- const fitted = fitWrapped({
+ // Fit in document-space units so visual result stays stable across zoom levels.
+ const fitted = fitWrappedText({
text: valueStr,
- maxWidth: widthPixels,
- maxHeight: heightPixels,
- startSize: block.field_schema?.size ?? 11,
+ fontFamily: resolvedFont.canvasFamily,
+ maxWidth: fitMaxWidthDoc,
+ maxHeight: fitMaxHeightDoc,
+ startSize: field.size ?? 11,
lineHeightMult: 1.0,
- zoom: scale,
});
- fontSize = fitted.fontSize;
- lineHeight = fitted.lineHeight;
+ fontSizeDoc = fitted.fontSize;
+ lineHeightDoc = fitted.lineHeight;
displayLines = fitted.lines || [];
} else {
- // No wrapping - find largest font size that fits on ONE line
+ // No wrapping - fit in document-space units.
const defaultSize = fieldType === "signature" ? 25 : 11;
- const fitted = fitNoWrap({
+ const fitted = fitNoWrapText({
text: valueStr,
- maxWidth: widthPixels,
- maxHeight: heightPixels,
- startSize: block.field_schema?.size ?? defaultSize,
+ fontFamily: resolvedFont.canvasFamily,
+ maxWidth: fitMaxWidthDoc,
+ maxHeight: fitMaxHeightDoc,
+ startSize: field.size ?? defaultSize,
});
- fontSize = fitted.fontSize;
- lineHeight = fontSize * 1.0;
+ fontSizeDoc = fitted.fontSize;
+ lineHeightDoc = fontSizeDoc * 1.0;
displayLines = [fitted.line];
}
} else {
- fontSize = block.field_schema?.size ?? 11;
- lineHeight = fontSize * 1.0;
+ fontSizeDoc = field.size ?? (fieldType === "signature" ? 25 : 11);
+ lineHeightDoc = fontSizeDoc * 1.0;
}
+ const fontSize = fontSizeDoc * scale;
+ const lineHeight = lineHeightDoc * scale;
+
const isSelected =
- animatingFieldId === fieldName || selectedFieldId === fieldName;
+ animatingFieldId === fieldName ||
+ selectedFieldId === fieldName ||
+ clickedHighlightFieldId === field.id;
+
+ const hasFieldError = !!fieldErrors[fieldName];
+ const isFieldValid = isFilled && !hasFieldError;
+ const borderColor = isOwnedByInitiator
+ ? isFieldValid
+ ? "#16a34a"
+ : "#dc2626"
+ : "#d1d5db";
+ const fillColor = isOwnedByInitiator
+ ? isFieldValid
+ ? "rgba(34, 197, 94, 0.2)"
+ : "rgba(239, 68, 68, 0.2)"
+ : "transparent";
+ const isClickable = isOwnedByInitiator;
+ const ownerLabel = field.signing_party_id
+ ? (signingPartyLabelById.get(field.signing_party_id) ??
+ "Unassigned")
+ : "Unassigned";
+ const showOwnerTooltip =
+ !isClickable &&
+ (hoveredFieldId === field.id ||
+ (isTouchInteraction && activeTouchFieldId === field.id));
return (
onFieldClick?.(fieldName)}
- className={`absolute cursor-pointer text-black transition-all ${isSelected ? "bg-green-300" : "bg-blue-200"} `}
+ key={field.id}
+ onMouseEnter={() => {
+ if (!isClickable) setHoveredFieldId(field.id);
+ }}
+ onMouseLeave={() => {
+ if (hoveredFieldId === field.id) setHoveredFieldId(null);
+ }}
+ onClick={(event) => {
+ event.stopPropagation();
+ if (isTouchInteraction) {
+ if (activeTouchFieldId !== field.id) {
+ setActiveTouchFieldId(field.id);
+ return;
+ }
+ setActiveTouchFieldId(null);
+ }
+ setClickedHighlightFieldId(field.id);
+ setTimeout(
+ () =>
+ setClickedHighlightFieldId((prev) =>
+ prev === field.id ? null : prev,
+ ),
+ 550,
+ );
+ if (!isClickable) return;
+ onFieldClick?.(fieldName);
+ }}
+ ref={(node) => registerFieldRef(fieldName, node)}
+ className={`absolute text-black transition-all ${
+ isClickable ? "cursor-pointer" : "cursor-default"
+ }`}
style={{
left: `${displayPos.displayX}px`,
top: `${displayPos.displayY}px`,
width: `${Math.max(widthPixels, 10)}px`,
- minHeight: `${Math.max(heightPixels, 10)}px`,
+ height: `${Math.max(heightPixels, 10)}px`,
overflow: "visible",
display: "flex",
+ backgroundColor: fillColor,
+ border: isSelected
+ ? `2px solid ${borderColor}`
+ : `1px solid ${borderColor}`,
+ zIndex: showOwnerTooltip ? 30 : isSelected ? 20 : 10,
alignItems:
align_v === "middle"
? "center"
@@ -766,38 +674,35 @@ const PdfPageWithFields = ({
? "flex-end"
: "flex-start",
}}
- title={`${label}: ${valueStr}`}
>
{isFilled && (
{displayLines.length > 0 ? displayLines.join("\n") : valueStr}
)}
+ {showOwnerTooltip ? (
+
+ ) : null}
);
})}
@@ -805,3 +710,11 @@ const PdfPageWithFields = ({
);
};
+
+const AssignedOwnerTooltip = ({ ownerLabel }: { ownerLabel: string }) => (
+
+
+ Filled by {ownerLabel}
+
+
+);
diff --git a/components/features/student/job/apply-to-job-button.tsx b/components/features/student/job/apply-to-job-button.tsx
index c1e0ec28..db32dafb 100644
--- a/components/features/student/job/apply-to-job-button.tsx
+++ b/components/features/student/job/apply-to-job-button.tsx
@@ -44,11 +44,7 @@ export const ApplyToJobButton = ({
!isProfileBaseComplete(profile) ||
profile.acknowledged_auto_apply === false
) {
- if(isMobile) {
- return router.push(`/profile/complete-profile?dest=search/${job.id}`);
- } else {
- return modalRegistry.incompleteProfile.open();
- }
+ return router.push(`/profile/complete-profile?dest=search/${job.id}`);
}
if (applied) {
diff --git a/components/forms/FormGenerateView.tsx b/components/forms/FormGenerateView.tsx
deleted file mode 100644
index 02f4987c..00000000
--- a/components/forms/FormGenerateView.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import { HeaderIcon, HeaderText } from "@/components/ui/text";
-import { Newspaper, MessageSquare } from "lucide-react";
-import FormTemplateCard from "@/components/features/student/forms/FormGenerateCard";
-import { useRouter } from "next/navigation";
-import { Loader } from "@/components/ui/loader";
-import { cn } from "@/lib/utils";
-import { Separator } from "@/components/ui/separator";
-import { HorizontalCollapsible } from "@/components/ui/horizontal-collapse";
-
-/**
- * Generate Forms View
- */
-export function FormGenerateView({
- formTemplates,
- isLoading,
-}: {
- formTemplates: any[] | undefined;
- isLoading: boolean;
-}) {
- const router = useRouter();
-
- return (
-
-
-
-
-
- Internship Forms
-
-
-
-
-
-
-
-
- {isLoading &&
Loading latest forms... }
-
- {!isLoading && (formTemplates?.length ?? 0) === 0 && (
-
-
There are no forms available yet for your department.
-
- )}
-
- {formTemplates?.length &&
- formTemplates
- .sort((a, b) => a.formName.localeCompare(b.formName))
- .map((form, i) => (
- router.push(`/forms/${form.formName}`)}
- />
- ))}
-
-
-
-
-
- );
-}
diff --git a/components/forms/FormHistoryView.tsx b/components/forms/FormHistoryView.tsx
index 736326cc..ba50a24e 100644
--- a/components/forms/FormHistoryView.tsx
+++ b/components/forms/FormHistoryView.tsx
@@ -1,17 +1,21 @@
"use client";
import { HeaderIcon, HeaderText } from "@/components/ui/text";
-import { Newspaper } from "lucide-react";
+import { Newspaper, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { FormLog } from "./FormLog";
import { IFormSigningParty } from "@betterinternship/core/forms";
-import { formatDate } from "@/lib/utils";
+import { cn, formatDate } from "@/lib/utils";
+import { Button } from "../ui/button";
+import { useAppContext } from "@/lib/ctx-app";
+import { useMemo } from "react";
interface FormHistoryViewProps {
+ formLabel?: string;
forms: Array<{
- form_process_id: string;
+ form_process_id?: string;
label: string;
prefilled_document_id?: string | null;
pending_document_id?: string | null;
@@ -21,57 +25,45 @@ interface FormHistoryViewProps {
signing_parties?: IFormSigningParty[];
status?: string | null;
rejection_reason?: string;
+ pending?: boolean;
}>;
}
/**
* Form History View
*/
-export function FormHistoryView({ forms }: FormHistoryViewProps) {
- return (
-
-
-
-
-
- Form History
-
-
{forms.length} generated forms
-
-
+export function FormHistoryView({ forms, formLabel }: FormHistoryViewProps) {
+ const filteredForms = useMemo(
+ () => forms.filter((form) => form.label === formLabel || !formLabel),
+ [forms, formLabel],
+ );
-
- {forms.length === 0 ? (
-
-
No forms yet
-
- You haven't generated any forms yet. Create your first form to
- get started!
-
-
- ) : (
- forms
- .toSorted(
- (a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp),
- )
- .map((form, index) => (
-
- ))
- )}
-
+ return (
+
+
+ {filteredForms.length === 0 ? (
+ <>>
+ ) : (
+ filteredForms
+ .toSorted(
+ (a, b) =>
+ Date.parse(b.timestamp ?? "") - Date.parse(a.timestamp ?? ""),
+ )
+ .map((form, index) => (
+
+ ))
+ )}
);
diff --git a/components/forms/FormLog.tsx b/components/forms/FormLog.tsx
index e1e93de1..eb96471b 100644
--- a/components/forms/FormLog.tsx
+++ b/components/forms/FormLog.tsx
@@ -11,7 +11,6 @@ import {
} from "lucide-react";
import { IFormSigningParty } from "@betterinternship/core/forms";
import { Badge } from "../ui/badge";
-import { Divider } from "../ui/divider";
import { Button } from "../ui/button";
import { SigningStatusTimeline } from "./SigningStatusTimeline";
import useModalRegistry from "../modals/modal-registry";
@@ -23,29 +22,29 @@ export const FormLog = ({
formProcessId,
label,
timestamp,
- documentId,
downloadUrl,
signingParties,
status,
rejectionReason,
+ pending,
index = -1,
}: {
- formProcessId: string;
- label: string;
+ formProcessId?: string;
+ label?: string;
timestamp: string;
- documentId?: string | null;
downloadUrl?: string | null;
signingParties?: IFormSigningParty[];
status?: string | null;
rejectionReason?: string;
index?: number;
+ pending?: boolean;
}) => {
const modalRegistry = useModalRegistry();
const [downloading, setDownloading] = useState(false);
const [isOpen, setIsOpen] = useState(index === 0);
const handleDownload = () => {
- if (!documentId) return;
+ if (!downloadUrl) return;
try {
setDownloading(true);
const a = document.createElement("a");
@@ -59,15 +58,45 @@ export const FormLog = ({
}
};
- if (rejectionReason) console.log(rejectionReason);
+ if (pending) {
+ return (
+
+
+ {/* Status Badge */}
+
+
+
+ Generating
+
+
+ {/* Content Section */}
+
+ {/* Header with Label, Timestamp, and Actions */}
+
+
+
+ {label}
+
+
{timestamp}
+
+
+
+
+
+ );
+ }
return (
(documentId ? handleDownload() : setIsOpen(!isOpen))}
+ className="hover:bg-slate-100 hover:cursor-pointer transition-all border-t"
+ onClick={() =>
+ downloadUrl && status === "done" ? handleDownload() : setIsOpen(!isOpen)
+ }
>
- {/* Status Badge */}
{status === "rejected" ? (
- {!rejectionReason && !documentId && (
+ {!rejectionReason && status !== "done" && (
<>
{
e.stopPropagation();
- modalRegistry.resendFormRequest.open(formProcessId);
+ if (formProcessId)
+ modalRegistry.resendFormRequest.open(formProcessId);
}}
>
Resend
@@ -136,24 +166,30 @@ export const FormLog = ({
variant="outline"
onClick={(e) => {
e.stopPropagation();
- modalRegistry.cancelFormRequest.open(formProcessId);
+ if (formProcessId)
+ modalRegistry.cancelFormRequest.open(formProcessId);
}}
>
Cancel
>
)}
- {documentId ? (
+ {status === "done" ? (
{
e.stopPropagation();
handleDownload();
}}
>
-
+ {!downloadUrl ? (
+
+ ) : (
+
+ )}
Download
) : (
@@ -175,7 +211,7 @@ export const FormLog = ({
{/* Mobile chevron/download */}
- {documentId ? (
+ {status === "done" ? (
-
+ {!downloadUrl ? (
+
+ ) : (
+
+ )}
Download
) : (
@@ -207,7 +247,7 @@ export const FormLog = ({
{/* Mobile action buttons */}
- {!rejectionReason && !documentId && (
+ {!rejectionReason && status !== "done" && (
{
e.stopPropagation();
- modalRegistry.resendFormRequest.open(formProcessId);
+ if (formProcessId)
+ modalRegistry.resendFormRequest.open(formProcessId);
}}
>
Resend
@@ -226,7 +267,8 @@ export const FormLog = ({
variant="outline"
onClick={(e) => {
e.stopPropagation();
- modalRegistry.cancelFormRequest.open(formProcessId);
+ if (formProcessId)
+ modalRegistry.cancelFormRequest.open(formProcessId);
}}
>
Cancel
@@ -235,7 +277,7 @@ export const FormLog = ({
)}
{/* Expandable Details Section */}
- {!documentId && isOpen && (
+ {status !== "done" && isOpen && (
{!!rejectionReason && (
diff --git a/components/modals/components/FormSubmissionSuccessModal.tsx b/components/modals/components/FormSubmissionSuccessModal.tsx
index ab0379c3..64ba81ee 100644
--- a/components/modals/components/FormSubmissionSuccessModal.tsx
+++ b/components/modals/components/FormSubmissionSuccessModal.tsx
@@ -1,15 +1,16 @@
import { Button } from "@/components/ui/button";
-import { useRouter } from "next/navigation";
-import { useEffect, useState } from "react";
+import useModalRegistry from "../modal-registry";
+import { IFormSigningParty } from "@betterinternship/core/forms";
+import { Badge } from "@/components/ui/badge";
function AnimatedCheck() {
return (
void;
}) {
- const router = useRouter();
- const [show, setShow] = useState(false);
-
- useEffect(() => {
- const timer = setTimeout(() => setShow(true), 100);
- return () => clearTimeout(timer);
- }, []);
+ const modalRegistry = useModalRegistry();
const handleNavigateToForms = () => {
onClose();
- router.push("/forms?view=history");
+ modalRegistry.formSubmissionSuccess.close();
};
const process = submissionType == "esign" ? "sent" : "generated";
console.log("FormSubmissionSuccessModal rendered with type:", submissionType);
return (
-
+
-
-
-
-
- Form {process} successfully
-
-
- You can now view and manage it in your forms.
-
+
+ {submissionType === "manual" ? (
+ <>
+
+ Form {process} successfully
+
+
+
+ You can now view and download it in your forms.
+
+ >
+ ) : (
+
+
+ Form {process} successfully
+
+
+
+
+ An initial email has been sent to the following address:
+
+
+ {firstRecipient?.signatory_account?.email}
+
+
+ Kindly ask them to check their Inbox or Spam folder.
+
+
+
+
+ Note: If they did not receive the email, there may be an issue
+ with the recipient's mailbox.
+
+
+
+
+ )}
Go to Forms
diff --git a/components/modals/components/IncompleteProfileModal.tsx b/components/modals/components/IncompleteProfileModal.tsx
deleted file mode 100644
index bd1ea55c..00000000
--- a/components/modals/components/IncompleteProfileModal.tsx
+++ /dev/null
@@ -1,720 +0,0 @@
-"use client";
-
-import React, { RefObject, useEffect, useMemo, useRef, useState } from "react";
-import {
- Upload,
- UserCheck,
- FileText,
- AlertTriangle,
- Repeat,
- User,
-} from "lucide-react";
-import { Card } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
-import { useAnalyzeResume } from "@/hooks/use-register";
-import ResumeUpload from "@/components/features/student/resume-parser/ResumeUpload";
-import { FormDropdown, FormInput } from "@/components/EditForm";
-import { UserService } from "@/lib/api/services";
-import { useProfileData } from "@/lib/api/student.data.api";
-import { Stepper } from "../../stepper/stepper";
-import { isProfileResume, isProfileBaseComplete } from "../../../lib/profile";
-import { ModalHandle } from "@/hooks/use-modal";
-import { isValidPHNumber } from "@/lib/utils";
-import { useDbRefs } from "@/lib/db/use-refs";
-import { Job } from "@/lib/db/db.types";
-import { isValidRequiredUserName } from "@/lib/utils/name-utils";
-import { DropdownGroup } from "@/components/ui/dropdown";
-
-/* ============================== Modal shell ============================== */
-
-export function IncompleteProfileContent({
- onFinish,
-}: {
- onFinish: () => void;
- applySuccessModalRef?: RefObject;
- job?: Job | null;
-}) {
- return (
-
-
-
-
-
-
- Let's finish setting up your profile
-
-
-
-
-
- );
-}
-
-/* ============================== Types ============================== */
-
-type ProfileDraft = {
- firstName?: string;
- middleName?: string;
- lastName?: string;
- phone?: string;
- university?: string;
- college?: string;
- department?: string;
- degree?: string;
-};
-
-type ResumeParsedUserSnake = {
- first_name?: string;
- middle_name?: string;
- last_name?: string;
- phone_number?: string;
- university?: string;
- degree?: string;
-};
-
-function snakeToDraft(u: ResumeParsedUserSnake): Partial {
- return {
- firstName: u.first_name,
- middleName: u.middle_name,
- lastName: u.last_name,
- phone: u.phone_number,
- // university: u.university,
- // degree: u.degree,
- };
-}
-
-/* ============================== Main Stepper ============================== */
-
-function CompleteProfileStepper({ onFinish }: { onFinish: () => void }) {
- const existingProfile = useProfileData();
- const [step, setStep] = useState(0);
- const [showComplete, setShowComplete] = useState(false);
-
- // profile being edited
- const [profile, setProfile] = useState(() => ({
- firstName: existingProfile.data?.first_name ?? "",
- middleName: existingProfile.data?.middle_name ?? "",
- lastName: existingProfile.data?.last_name ?? "",
- phone: existingProfile.data?.phone_number ?? "",
- university: existingProfile.data?.university ?? "",
- college: existingProfile.data?.college ?? "",
- department: existingProfile.data?.department ?? "",
- degree: existingProfile.data?.degree ?? "",
- }));
-
- const [autoApply, setAutoApply] = useState(true);
-
- // parsing/upload states
- const [file, setFile] = useState(null);
- const [isParsing, setIsParsing] = useState(false);
- const [isUpdating, setIsUpdating] = useState(false);
- const [parsedReady, setParsedReady] = useState(false);
- const [parseError, setParseError] = useState(null);
-
- // analyze
- const { upload, fileInputRef, response } = useAnalyzeResume(file);
- const handledResponseRef = useRef | null>(null);
-
- // upload on file
- useEffect(() => {
- if (!file) return;
- setParseError(null);
- setParsedReady(false);
- setIsParsing(true);
- void upload(file);
- }, [file]);
-
- // hydrate once per promise
- useEffect(() => {
- if (!response || handledResponseRef.current === response) return;
- handledResponseRef.current = response;
-
- let cancelled = false;
- response
- .then(({ extractedUser }: { extractedUser?: ResumeParsedUserSnake }) => {
- if (cancelled) return;
- if (extractedUser) {
- const patch = snakeToDraft(extractedUser);
- setProfile((p) => ({ ...p, ...patch }));
- }
- setParsedReady(true);
- setIsParsing(false);
- setStep(1);
- })
- .catch((e: Error) => {
- if (!cancelled) {
- setParseError(e?.message || "Failed to analyze resume.");
- setIsParsing(false);
- setParsedReady(false);
- }
- });
-
- return () => {
- cancelled = true;
- };
- }, [response]);
-
- const steps = useMemo(() => {
- const s: Array<{
- id: "resume" | "base" | "auto-apply";
- title: string;
- subtitle?: string;
- icon: any;
- canNext: () => boolean;
- component: React.ReactNode;
- }> = [];
-
- if (!isProfileResume(existingProfile.data)) {
- s.push({
- id: "resume",
- title: "Upload your resume",
- subtitle: "Upload a PDF and we'll auto-fill what we can.",
- icon: Upload,
- canNext: () => !!file && !isParsing,
- component: (
- setFile(f)}
- fileInputRef={
- fileInputRef as unknown as RefObject
- }
- response={response ?? null}
- />
- ),
- });
- }
-
- if (!isProfileBaseComplete(existingProfile.data)) {
- s.push({
- id: "base",
- title: "Basic details",
- icon: UserCheck,
- canNext: () => {
- const phoneValid = isValidPHNumber(profile.phone);
- return (
- !!profile.firstName &&
- isValidRequiredUserName(profile.firstName) &&
- !!profile.lastName &&
- isValidRequiredUserName(profile.lastName) &&
- !isUpdating &&
- phoneValid &&
- !!profile.degree &&
- !!profile.college &&
- !!profile.department
- );
- },
- component: ,
- });
- }
-
- if (existingProfile.data?.acknowledged_auto_apply === false) {
- s.push({
- id: "auto-apply",
- title: "Auto-apply settings",
- icon: Repeat,
- canNext: () => autoApply !== null && !isUpdating,
- component: ,
- });
- }
-
- return s;
- }, [
- isParsing,
- parsedReady,
- parseError,
- response,
- existingProfile.data,
- profile,
- isUpdating,
- autoApply,
- ]);
-
- useEffect(() => {
- if (steps.length === 0) {
- setShowComplete(true);
- }
- }, [steps.length]);
-
- // Next behavior
- const onNext = async () => {
- const current = steps[step];
-
- console.log(current);
-
- if (!current) return;
-
- if (current.id === "resume") {
- setStep(step + 1);
- return;
- }
-
- if (current.id === "base") {
- setIsUpdating(true);
- const fullName = [
- profile?.firstName ?? "",
- profile?.middleName ?? "",
- profile?.lastName ?? "",
- ]
- .filter(Boolean)
- .join(" ");
- await UserService.updateMyProfile({
- first_name: profile.firstName ?? "",
- middle_name: profile.middleName ?? "",
- last_name: profile.lastName ?? "",
- phone_number: profile.phone ?? "",
- university: profile.university ?? "",
- degree: profile.degree ?? "",
- college: profile.college ?? "",
- department: profile.department ?? "",
- internship_moa_fields: {
- student: {
- "student-degree": profile.degree ?? "",
- "student-college": profile.college ?? "",
- "student-full-name": fullName,
- "student-first-name": profile.firstName ?? "",
- "student-middle-name": profile.middleName ?? "",
- "student-last-name": profile.lastName ?? "",
- "student-department": profile.department ?? "",
- "student-university": profile.university ?? "",
- "student-phone-number": profile.phone ?? "",
- },
- },
- })
- .then(() => {
- setIsUpdating(false);
- const isLast = step + 1 >= steps.length;
- if (isLast) setShowComplete(true);
- else setStep(step + 1);
- })
- .catch((e) => console.log(e));
- return;
- }
-
- if (current.id === "auto-apply") {
- if (autoApply === null) return;
-
- setIsUpdating(true);
- await UserService.updateMyProfile({
- acknowledged_auto_apply: true,
- apply_for_me: autoApply,
- }).then(() => {
- setIsUpdating(false);
- const isLast = step + 1 >= steps.length;
- if (isLast) setShowComplete(true);
- else setStep(step + 1);
- });
- return;
- }
- };
-
- if (showComplete) {
- return ;
- }
-
- return (
-
- void onNext()}
- onBack={() => setStep(Math.max(0, step - 1))}
- />
-
- );
-}
-
-/* ---------------------- Step: Resume Upload ---------------------- */
-
-function StepResume({
- file,
- isParsing,
- parsedReady,
- parseError,
- onPick,
- fileInputRef,
- response,
-}: {
- file: File | null;
- isParsing: boolean;
- parsedReady: boolean;
- parseError: string | null;
- onPick: (file: File) => void;
- fileInputRef: React.RefObject;
- response: Promise | null;
-}) {
- return (
-
-
onPick(f)}
- onComplete={() => {}}
- />
-
- {file && (
-
-
-
-
-
{file.name}
-
- {(file.size / 1024 / 1024).toFixed(2)} MB
-
-
-
- {parsedReady ? (
-
Parsed
- ) : isParsing ? (
-
Parsing...
- ) : (
-
Waiting...
- )}
-
- )}
-
- {parseError && (
-
- )}
-
- );
-}
-
-/* ---------------------- Step: Basic Identity ---------------------- */
-
-function StepBasicIdentity({
- value,
- onChange,
-}: {
- value: ProfileDraft;
- onChange: (v: ProfileDraft) => void;
-}) {
- const {
- colleges,
- departments,
- get_departments_by_college,
- to_department_name,
- to_university_name,
- get_colleges_by_university,
- to_college_name,
- } = useDbRefs();
-
- const phoneInvalid = useMemo(
- () => !!value.phone && !isValidPHNumber(value.phone),
- [value.phone],
- );
-
- const firstNameInvalid = useMemo(
- () => !!value.firstName && !isValidRequiredUserName(value.firstName),
- [value.firstName],
- );
-
- const lastNameInvalid = useMemo(
- () => !!value.lastName && !isValidRequiredUserName(value.lastName),
- [value.lastName],
- );
-
- const [departmentOptions, setDepartmentOptions] =
- useState<{ id: string; name: string }[]>(departments);
-
- const [collegesOptions, setCollegesOptions] =
- useState<
- { id: string; name: string; short_name: string; university_id: string }[]
- >(colleges);
-
- // for realtime updating the department based on the college
- useEffect(() => {
- const collegeId = value.college;
-
- if (collegeId) {
- const list = get_departments_by_college?.(collegeId);
- setDepartmentOptions(
- list.map((d) => ({
- id: d,
- name: to_department_name(d) ?? "",
- })),
- );
- } else {
- // no college selected -> empty department options
- setDepartmentOptions(
- departments.map((d) => ({ id: d.id, name: d.name })),
- );
- if (value.department) {
- onChange({ ...value, department: undefined });
- }
- }
- }, [
- value.college,
- value.department,
- departments,
- get_departments_by_college,
- to_department_name,
- onChange,
- ]);
-
- // for realtime updating the college based on the university
- useEffect(() => {
- const universityId = value.university;
-
- if (universityId) {
- const list = get_colleges_by_university?.(universityId) ?? [];
- const mapped = list.map((d) => ({
- id: d,
- name: to_college_name(d) ?? "",
- short_name: "",
- university_id: universityId,
- }));
- setCollegesOptions(mapped);
-
- // If the currently selected college is not in the new mapped list, clear it (and department)
- if (value.college && !mapped.some((c) => c.id === value.college)) {
- onChange({ ...value, college: undefined, department: undefined });
- }
- } else {
- // no university selected -> show all colleges and clear college/department
- setCollegesOptions(
- colleges.map((d) => ({
- id: d.id,
- name: d.name,
- short_name: d.short_name,
- university_id: d.university_id,
- })),
- );
-
- // Clear selected college and department because no university is chosen
- if (value.college || value.department) {
- onChange({ ...value, college: undefined, department: undefined });
- }
- }
- }, [
- value.university,
- value.college,
- value.department,
- colleges,
- get_colleges_by_university,
- to_college_name,
- onChange,
- ]);
-
- return (
-
- {/* Personal */}
-
-
- Personal information
-
-
-
- onChange({ ...value, firstName: v })}
- />
-
- onChange({ ...value, middleName: v })}
- />
- onChange({ ...value, lastName: v })}
- />
-
-
- {firstNameInvalid && (
-
- Please enter a first name. Special characters are not allowed.
-
- )}
- {lastNameInvalid && (
-
- Please enter a last name. Special characters are not allowed.
-
- )}
-
-
onChange({ ...value, phone: v })}
- />
- {phoneInvalid && (
-
- Please enter a valid mobile number (e.g., 639XXXXXXXXX).
-
- )}
-
-
-
- {/* Education */}
-
-
- Educational background
-
-
-
- onChange({ ...value, college: val.toString() })}
- options={collegesOptions}
- placeholder="Select college…"
- />
-
-
-
-
- onChange({ ...value, department: val.toString() })
- }
- options={departmentOptions}
- placeholder="Select department…"
- disabled={value.college === ""}
- />
-
-
- onChange({ ...value, degree: val.toString() })}
- placeholder="Select degree / program…"
- disabled={value.department === ""}
- />
-
-
-
-
- );
-}
-
-/* ---------------------- Step: Auto-Apply Acknowledge ---------------------- */
-
-function StepAutoApply({
- value,
- onChange,
-}: {
- value: boolean | null;
- onChange: (v: boolean) => void;
-}) {
- return (
-
-
-
-
-
Turn on Auto-Apply?
-
- Recommended
-
-
-
-
- We’ll auto-submit matching roles using your saved details.{" "}
-
-
- {/* Native select to avoid extra deps; replace with shadcn Select if you prefer */}
-
-
Auto-Apply preference
-
onChange(e.target.value === "yes")}
- >
- Yes, enable
- No, not now
-
-
- You can change this anytime in your profile.
-
-
-
-
-
- );
-}
-
-/* ---------------------- Completion (rendered OUTSIDE stepper) ---------------------- */
-
-function StepComplete({ onDone }: { onDone: () => void }) {
- useEffect(() => {
- const t = setTimeout(() => onDone(), 1400);
- return () => clearTimeout(t);
- }, [onDone]);
-
- return (
-
- {/* Check animation */}
-
-
-
Profile complete
-
- You’re all set. Nice work!
-
-
-
-
- );
-}
-
-/* ============================== Exports ============================== */
-export { CompleteProfileStepper };
diff --git a/components/modals/components/MassApplyJobsSelector.tsx b/components/modals/components/MassApplyJobsSelector.tsx
index c71af192..6e432d1e 100644
--- a/components/modals/components/MassApplyJobsSelector.tsx
+++ b/components/modals/components/MassApplyJobsSelector.tsx
@@ -6,6 +6,7 @@ import { JobCard, JobDetails } from "@/components/shared/jobs";
import { useJobsData } from "@/lib/api/student.data.api";
import { useMassApply } from "@/lib/api/god.api";
import { Job } from "@/lib/db/db.types";
+import { useAuthContext } from "@/lib/ctx-auth";
interface MassApplyJobsSelectorProps {
selectedStudentIds: Set;
@@ -22,6 +23,7 @@ export function MassApplyJobsSelector({
const [jobsPage, setJobsPage] = useState(1);
const jobsPageSize = 10;
const massApply = useMassApply();
+ const { isAuthenticated } = useAuthContext();
// Use the same hook as student search page to fetch jobs
const jobs = useJobsData({
@@ -161,7 +163,10 @@ export function MassApplyJobsSelector({
{selectedJob ? (
<>
-
+
diff --git a/components/modals/components/SigningPartyTimeline.tsx b/components/modals/components/SigningPartyTimeline.tsx
deleted file mode 100644
index 373cf418..00000000
--- a/components/modals/components/SigningPartyTimeline.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { IFormSigningParty } from "@betterinternship/core/forms";
-import { Timeline, TimelineItem } from "@/components/ui/timeline";
-
-interface SigningPartyTimelineProps {
- signingParties?: IFormSigningParty[];
-}
-
-export const SigningPartyTimeline = ({
- signingParties,
-}: SigningPartyTimelineProps) => {
- if (!signingParties || signingParties.length === 0) {
- return null;
- }
-
- return (
-
- {signingParties.map((party, index) => {
- // Find the source party's title if signatory_source exists
- let sourceTitle = "";
- if (party.signatory_source?._id) {
- const sourceParty = signingParties.find(
- (p) => p._id === party.signatory_source?._id,
- );
- sourceTitle = sourceParty?.signatory_title.trim() || "";
- }
-
- const isSourceFromYou = sourceTitle === "Student";
- const isYou = party.signatory_title == "Student";
-
- let displayTitle = party.signatory_title;
- if (isYou) {
- displayTitle = "You";
- }
-
- return (
-
- You
-
- ) : (
- displayTitle
- )
- }
- subtitle={
- sourceTitle && (
-
-
- email coming from{" "}
- {isSourceFromYou ? (
-
- You
-
- ) : (
- sourceTitle
- )}
-
-
- )
- }
- isLast={index === signingParties.length - 1}
- />
- );
- })}
-
- );
-};
diff --git a/components/modals/components/SpecifySigningPartiesModal.tsx b/components/modals/components/SpecifySigningPartiesModal.tsx
deleted file mode 100644
index b4cfb514..00000000
--- a/components/modals/components/SpecifySigningPartiesModal.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import { useState } from "react";
-import { Button } from "@/components/ui/button";
-import { FieldRenderer } from "@/components/features/student/forms/FieldRenderer";
-import { getBlockField } from "@/components/features/student/forms/utils";
-import {
- ClientBlock,
- ClientField,
- ClientPhantomField,
- FormErrors,
- FormValues,
- IFormSigningParty,
-} from "@betterinternship/core/forms";
-import { PublicUser } from "@/lib/db/db.types";
-import { TextLoader } from "@/components/ui/loader";
-import { IFormFiller } from "@/components/features/student/forms/form-filler.ctx";
-import { useQueryClient } from "@tanstack/react-query";
-import useModalRegistry from "../modal-registry";
-import { ChevronDown } from "lucide-react";
-import { SigningPartyTimeline } from "./SigningPartyTimeline";
-
-export const SpecifySigningPartiesModal = ({
- fields,
- formFiller,
- autofillValues,
- signingPartyBlocks,
- handleSubmit,
- close,
- signingParties,
-}: {
- fields: (ClientField<[PublicUser]> | ClientPhantomField<[PublicUser]>)[];
- formFiller: IFormFiller;
- autofillValues?: FormValues;
- signingPartyBlocks: ClientBlock<[PublicUser]>[];
- handleSubmit: (
- signingPartyValues: FormValues,
- ) => Promise<{ success?: boolean; message?: string }>;
- close: () => void;
- signingParties?: IFormSigningParty[];
-}) => {
- const queryClient = useQueryClient();
- const modalRegistry = useModalRegistry();
- const [errors, setErrors] = useState
({});
- const [signingPartyValues, setSigningPartyValues] = useState({});
- const [busy, setBusy] = useState(false);
- const [submitted, setSubmitted] = useState(false);
- const [isProcessStoryOpen, setIsProcessStoryOpen] = useState(false);
-
- const handleClick = async () => {
- setBusy(true);
-
- // Derive stuff we need
- const signingPartyFields = signingPartyBlocks
- .map((block) => getBlockField(block))
- .filter((field) => !!field);
- const additionalValues = {
- ...autofillValues,
- ...signingPartyValues,
- };
-
- // Try to validate the emails
- const errors = formFiller.validate(
- [...fields, ...signingPartyFields],
- additionalValues,
- );
- setErrors(errors);
-
- if (Object.keys(errors).length) {
- setBusy(false);
- return;
- }
-
- const response = await handleSubmit(
- formFiller.getFinalValues(additionalValues),
- );
-
- if (!response.success) {
- setBusy(false);
- alert("Something went wrong, please try again.");
- console.error(response.message);
- return;
- }
-
- // Invalidate queries
- await queryClient.invalidateQueries({ queryKey: ["my-forms"] });
- setSubmitted(true);
- setBusy(false);
- close();
- modalRegistry.formSubmissionSuccess.open("esign");
- };
-
- return (
-
-
- This form requires signatures from other parties. Enter their emails
- below and we'll send them the form to sign.
-
-
- {signingPartyBlocks.map((block) => {
- const field = getBlockField(block);
- if (!field) return <>>;
- return (
-
- setSigningPartyValues({
- ...signingPartyValues,
- [field.field]: value,
- })
- }
- >
- );
- })}
-
- {/* Process Story */}
-
setIsProcessStoryOpen(!isProcessStoryOpen)}
- className="mt-4 w-full flex items-center gap-2 text-xs text-gray-600 hover:text-primary transition-colors"
- >
- View signing order
-
-
-
- {isProcessStoryOpen && (
-
-
-
- )}
-
-
- {!busy && !submitted && (
-
- Cancel
-
- )}
- void handleClick()}>
- Sign and send form
-
-
-
- );
-};
diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx
index 98551c2f..c5bd098a 100644
--- a/components/modals/modal-registry.tsx
+++ b/components/modals/modal-registry.tsx
@@ -1,29 +1,23 @@
-import { useQueryClient } from "@tanstack/react-query";
-import { useGlobalModal } from "../providers/ModalProvider";
-import { AlertCircle, CheckCircle, LucideIcon } from "lucide-react";
-
-import { IncompleteProfileContent } from "./components/IncompleteProfileModal";
+import { useGlobalModal } from "../providers/modal-provider/ModalProvider";
+import { LucideIcon } from "lucide-react";
import { MassApplyComposer } from "./components/MassApplyComposer";
-import {
- MassApplyResults,
- MassApplyResultsData,
-} from "./components/MassApplyResults";
-import { SpecifySigningPartiesModal } from "./components/SpecifySigningPartiesModal";
import { FormSubmissionSuccessModal } from "./components/FormSubmissionSuccessModal";
-import {
- ClientBlock,
- ClientField,
- ClientPhantomField,
- FormValues,
- IFormSigningParty,
-} from "@betterinternship/core/forms";
-import { PublicUser } from "@/lib/db/db.types";
-import { IFormFiller } from "../features/student/forms/form-filler.ctx";
import { ResendFormModal } from "./components/ResendFormModal";
import { CancelFormModal } from "./components/CancelFormModal";
import { WarningModal } from "./components/WarningModal";
import { SuccessModal } from "./components/SuccessModal";
import { MassApplyJobsSelector } from "./components/MassApplyJobsSelector";
+import {
+ DefaultModalLayout,
+ SlideUpModalLayout,
+} from "../providers/modal-provider/ModalLayout";
+import { ReactNode } from "react";
+import {
+ MassApplyResults,
+ MassApplyResultsData,
+} from "./components/MassApplyResults";
+import { FormPreviewPdfDisplay } from "../features/student/forms/previewer";
+import { IFormSigningParty } from "@betterinternship/core/forms";
/**
* Simplifies modal config since we usually reuse each of these modal stuffs.
@@ -31,32 +25,9 @@ import { MassApplyJobsSelector } from "./components/MassApplyJobsSelector";
* @returns
*/
export const useModalRegistry = () => {
- const { open, close } = useGlobalModal();
- const queryClient = useQueryClient();
+ const { openModal: open, closeModal: close } = useGlobalModal();
const modalRegistry = {
- // The modal shown when someone's profile is incomplete
- incompleteProfile: {
- open: () =>
- open(
- "incomplete-profile",
- {
- void queryClient
- .invalidateQueries({ queryKey: ["my-profile"] })
- .then(() => close("incomplete-profile"));
- }}
- />,
- {
- allowBackdropClick: false,
- showHeaderDivider: true,
- title: "Complete your profile",
- onClose: () => close("incomplete-profile"),
- },
- ),
- close: () => close("incomplete-profile"),
- },
-
// Mass apply fill-out modal
massApplyCompose: {
open: ({
@@ -74,6 +45,7 @@ export const useModalRegistry = () => {
}) =>
open(
"mass-apply-compose",
+ DefaultModalLayout,
{
/>,
{
title: `Apply to ${selectedCount} selected`,
- allowBackdropClick: false,
+ closeOnBackdropClick: false,
},
),
close: () => close("mass-apply-compose"),
@@ -107,6 +79,7 @@ export const useModalRegistry = () => {
}) =>
open(
"mass-apply-results",
+ DefaultModalLayout,
close("mass-apply-results")}
@@ -124,54 +97,25 @@ export const useModalRegistry = () => {
close: () => close("mass-apply-results"),
},
- // Email confirmation modal
- specifySigningParties: {
- open: (
- fields: (
- | ClientField<[PublicUser]>
- | ClientPhantomField<[PublicUser]>
- )[],
- formFiller: IFormFiller,
- signingPartyBlocks: ClientBlock<[PublicUser]>[],
- handleSubmit: (signingPartyValues: FormValues) => Promise,
- autofillValues?: FormValues,
- signingParties?: IFormSigningParty[],
- ) =>
- open(
- "specify-signing-parties",
- close("specify-signing-parties")}
- autofillValues={autofillValues}
- signingParties={signingParties}
- />,
- {
- title: "Next Signing Parties",
- closeOnEsc: false,
- allowBackdropClick: false,
- hasClose: false,
- showHeaderDivider: true,
- },
- ),
- close: () => close("specify-signing-parties"),
- },
-
// Form submission success modal
formSubmissionSuccess: {
- open: (submissionType: "esign" | "manual" | null) =>
+ open: (
+ submissionType: "esign" | "manual" | null,
+ onClose: () => void,
+ firstRecipient?: IFormSigningParty,
+ ) =>
open(
"form-submission-success",
+ DefaultModalLayout,
close("form-submission-success")}
+ onClose={onClose}
+ firstRecipient={firstRecipient}
/>,
{
- closeOnEsc: false,
- allowBackdropClick: false,
- hasClose: false,
+ closeOnEscapeKey: false,
+ closeOnBackdropClick: false,
+ showCloseButton: false,
onClose: () => close("form-submission-success"),
},
),
@@ -183,12 +127,13 @@ export const useModalRegistry = () => {
open: (formProcessId: string) =>
open(
"cancel-form-request",
+ DefaultModalLayout,
,
{
title: "Cancel this form request?",
- hasClose: false,
- allowBackdropClick: false,
- closeOnEsc: false,
+ showCloseButton: false,
+ closeOnBackdropClick: false,
+ closeOnEscapeKey: false,
showHeaderDivider: true,
},
),
@@ -200,72 +145,19 @@ export const useModalRegistry = () => {
open: (formProcessId: string) =>
open(
"resend-form-request",
+ DefaultModalLayout,
,
{
title: "Resend form email?",
- hasClose: false,
- allowBackdropClick: false,
- closeOnEsc: false,
+ showCloseButton: false,
+ closeOnBackdropClick: false,
+ closeOnEscapeKey: false,
showHeaderDivider: true,
},
),
close: () => close("resend-form-request"),
},
- // Duplicate form warning
- duplicateFormWarning: {
- open: ({
- formLabel,
- hasPendingInstance,
- hasCompletedInstance: _hasCompletedInstance,
- onGenerateAnother,
- onGoBack,
- }: {
- formLabel: string;
- hasPendingInstance: boolean;
- hasCompletedInstance: boolean;
- onGenerateAnother: () => void;
- onGoBack: () => void;
- }) => {
- const isPending = hasPendingInstance;
-
- const title = isPending
- ? "You already have an outgoing instance of this form: " +
- "\n" +
- formLabel
- : "You've already generated this form before: " + formLabel;
-
- const message = isPending
- ? "This form is currently being filled out by other signatories. It is highly recommended to cancel the pending attempt before starting a new one. Multiple outgoing versions may cause confusion."
- : "You already have a completed version of this form: " +
- formLabel +
- ". Are you sure you want another one?";
-
- return open(
- "duplicate-form-warning",
- close("duplicate-form-warning")}
- />,
- {
- title: " ",
- allowBackdropClick: false,
- closeOnEsc: false,
- hasClose: false,
- },
- );
- },
- close: () => close("duplicate-form-warning"),
- },
-
// Generic warning modal for reuse
warning: {
open: ({
@@ -285,6 +177,7 @@ export const useModalRegistry = () => {
}) =>
open(
"warning",
+ DefaultModalLayout,
{
/>,
{
title: " ",
- allowBackdropClick: false,
- closeOnEsc: false,
- hasClose: false,
+ closeOnBackdropClick: false,
+ closeOnEscapeKey: false,
+ showCloseButton: false,
},
),
close: () => close("warning"),
@@ -323,6 +216,7 @@ export const useModalRegistry = () => {
}) =>
open(
"success",
+ DefaultModalLayout,
{
/>,
{
title: " ",
- allowBackdropClick: false,
- closeOnEsc: false,
- hasClose: false,
+ closeOnBackdropClick: false,
+ closeOnEscapeKey: false,
+ showCloseButton: false,
},
),
close: () => close("success"),
@@ -355,6 +249,7 @@ export const useModalRegistry = () => {
}) =>
open(
"mass-apply-job-selector",
+ DefaultModalLayout,
{
@@ -369,6 +264,57 @@ export const useModalRegistry = () => {
),
close: () => close("mass-apply-job-selector"),
},
+
+ previewFormPdf: {
+ open: ({ documentUrl }: { documentUrl: string }) =>
+ open(
+ "preview-form-pdf",
+ SlideUpModalLayout,
+ ,
+ {
+ title: "PDF Preview",
+ },
+ ),
+ close: () => close("preview-form-pdf"),
+ },
+
+ formTemplateDetails: {
+ open: ({
+ title,
+ content,
+ onClose,
+ onRequestClose,
+ showCloseButton,
+ closeOnBackdropClick,
+ closeOnEscapeKey,
+ mobileFullscreen,
+ }: {
+ title?: ReactNode;
+ content: ReactNode;
+ onClose?: () => void;
+ onRequestClose?: () => void;
+ showCloseButton?: boolean;
+ closeOnBackdropClick?: boolean;
+ closeOnEscapeKey?: boolean;
+ mobileFullscreen?: boolean;
+ }) =>
+ open("form-template-details", SlideUpModalLayout, content, {
+ title,
+ onClose,
+ onRequestClose,
+ showCloseButton,
+ closeOnBackdropClick,
+ closeOnEscapeKey,
+ mobileFullscreen,
+ }),
+ close: () => close("form-template-details"),
+ },
+
+ closeAll: () => close(),
};
return modalRegistry;
diff --git a/components/providers/ModalProvider.tsx b/components/providers/ModalProvider.tsx
deleted file mode 100644
index ae202fe1..00000000
--- a/components/providers/ModalProvider.tsx
+++ /dev/null
@@ -1,227 +0,0 @@
-"use client";
-
-import React, {
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from "react";
-import { createPortal } from "react-dom";
-import { X } from "lucide-react";
-import { AnimatePresence, motion } from "framer-motion";
-
-type ModalOptions = {
- allowBackdropClick?: boolean; // default: true
- closeOnEsc?: boolean; // default: true
- hasClose?: boolean; // default: true
- onClose?: () => void; // called AFTER the modal is removed
- backdropClassName?: string;
- panelClassName?: string;
- title?: React.ReactNode;
- headerClassName?: string;
- showHeaderDivider?: boolean;
- useCustomPanel?: boolean;
-};
-
-type RegistryEntry = { node: React.ReactNode; opts: ModalOptions };
-type Open = (
- name: string,
- content: React.ReactNode,
- opts?: ModalOptions,
-) => void;
-type Close = (name?: string) => void;
-
-const ModalCtx = createContext<{ open: Open; close: Close }>({
- open: () => {},
- close: () => {},
-});
-
-export const useGlobalModal = () => useContext(ModalCtx);
-
-export function ModalProvider({ children }: { children: React.ReactNode }) {
- const [registry, setRegistry] = useState>({});
-
- const open = useCallback((name, content, opts = {}) => {
- setRegistry((m) => ({ ...m, [name]: { node: content, opts } }));
- }, []);
-
- const close = useCallback((name) => {
- setRegistry((m) => {
- if (!name) {
- Object.values(m).forEach((e) => e.opts.onClose?.());
- return {};
- }
- const entry = m[name];
- entry?.opts.onClose?.();
- const { [name]: _removed, ...rest } = m;
- return rest;
- });
- }, []);
-
- // Body scroll lock + iOS --vh fix when ANY modal is open
- useEffect(() => {
- const count = Object.keys(registry).length;
- if (count === 0) return;
-
- const originalOverflow = document.body.style.overflow;
- document.body.style.overflow = "hidden";
-
- const setVH = () =>
- document.documentElement.style.setProperty(
- "--vh",
- `${window.innerHeight * 0.01}px`,
- );
-
- setVH();
- window.addEventListener("resize", setVH);
- window.addEventListener("orientationchange", setVH);
-
- return () => {
- document.body.style.overflow = originalOverflow;
- document.documentElement.style.removeProperty("--vh");
- window.removeEventListener("resize", setVH);
- window.removeEventListener("orientationchange", setVH);
- };
- }, [registry]);
-
- // ESC to close the top-most modal that allows it
- useEffect(() => {
- const handler = (e: KeyboardEvent) => {
- if (e.key !== "Escape") return;
- const entries = Object.entries(registry);
- if (!entries.length) return;
- const [lastName, lastEntry] = entries[entries.length - 1];
- if (lastEntry.opts.closeOnEsc !== false) close(lastName);
- };
- document.addEventListener("keydown", handler);
- return () => document.removeEventListener("keydown", handler);
- }, [registry, close]);
-
- const portals = useMemo(() => {
- const entries = Object.entries(registry);
- if (!entries.length) return null;
-
- return createPortal(
-
- {entries.map(([name, { node, opts }]) => {
- const backdropRef = React.createRef();
-
- return (
- {
- if (opts.allowBackdropClick === false) return;
- if (e.target === backdropRef.current) close(name);
- }}
- onTouchEnd={(e) => {
- if (opts.allowBackdropClick === false) return;
- if (e.target === backdropRef.current) close(name);
- }}
- >
- {!opts.useCustomPanel ? (
- e.stopPropagation()}
- >
- {/* Header row: title (left) + close (right) */}
- {(opts.title || opts.hasClose !== false) && (
-
- {/* Title (optional) */}
- {opts.title ? (
- typeof opts.title === "string" ? (
-
- {opts.title}
-
- ) : (
-
{opts.title}
- )
- ) : (
-
- )}
-
- {/* Close button (optional) */}
- {opts.hasClose !== false && (
-
close(name)}
- className="h-8 w-8 rounded-full hover:bg-gray-100 active:bg-gray-200 flex items-center justify-center shrink-0"
- >
-
-
- )}
-
- )}
-
- {/* Content area */}
-
-
- {/* Mobile safe area spacer */}
-
-
- ) : (
- node
- )}
-
- );
- })}
- ,
- document.body,
- );
- }, [registry, close]);
-
- return (
-
- {children}
- {portals}
-
- );
-}
diff --git a/components/providers/modal-provider/ModalLayout.tsx b/components/providers/modal-provider/ModalLayout.tsx
new file mode 100644
index 00000000..9dff865c
--- /dev/null
+++ b/components/providers/modal-provider/ModalLayout.tsx
@@ -0,0 +1,177 @@
+/**
+ * @ Author: BetterInternship
+ * @ Create Time: 2026-03-04 17:30:31
+ * @ Modified time: 2026-03-05 16:45:36
+ * @ Description:
+ *
+ * This file contains reusable modal layouts so we don't have to pass too many options when opening modals.
+ * Think of it this way: we have modals that lay on the center of the page, modals that pop up from below, etc.
+ * Instead of passing options to change between these modals and let the ModalProvider make all the decisions,
+ * we pass a template of each of these modals instead.
+ * This keeps the ModalProvider cleaner and prevents it from becoming a god class.
+ */
+
+import { cn } from "@/lib/utils";
+import { motion } from "framer-motion";
+import { X } from "lucide-react";
+import { ModalContext, ModalInjectedParams } from "./ModalProvider";
+import { Button } from "@/components/ui/button";
+
+interface DefaultModalLayout {
+ title?: React.ReactNode;
+ showHeaderDivider?: boolean;
+ showCloseButton?: boolean;
+}
+
+export const DefaultModalLayout = ({
+ name,
+ title,
+ children,
+ showHeaderDivider,
+ showCloseButton,
+ closeModal,
+}: DefaultModalLayout & ModalInjectedParams & ModalContext) => {
+ return (
+ e.stopPropagation()}
+ >
+ {(title || showCloseButton !== false) && (
+
+ {title ? (
+ typeof title === "string" ? (
+
{title}
+ ) : (
+
{title}
+ )
+ ) : (
+
+ )}
+ {showCloseButton !== false && (
+
closeModal(name)}
+ className="h-8 w-8 rounded-full hover:bg-gray-100 active:bg-gray-200 flex items-center justify-center shrink-0"
+ >
+
+
+ )}
+
+ )}
+
+
+
+
+
+ );
+};
+
+interface SlideUpModalLayout {
+ title?: React.ReactNode;
+ showHeaderDivider?: boolean;
+ showCloseButton?: boolean;
+ onRequestClose?: () => void;
+ mobileFullscreen?: boolean;
+}
+
+export const SlideUpModalLayout = ({
+ name,
+ title,
+ children,
+ showCloseButton,
+ onRequestClose,
+ mobileFullscreen,
+ closeModal,
+}: SlideUpModalLayout & ModalInjectedParams & ModalContext) => {
+ const shouldShowCloseButton = showCloseButton !== false;
+ const handleClose = () => {
+ if (onRequestClose) {
+ onRequestClose();
+ return;
+ }
+ closeModal(name);
+ };
+
+ return (
+ <>
+ {shouldShowCloseButton && (
+
+ )}
+
+
+
+
+ {title ?? "Details"}
+
+ {shouldShowCloseButton && (
+
+
+
+ )}
+
+
{children}
+
+
+ >
+ );
+};
diff --git a/components/providers/modal-provider/ModalProvider.tsx b/components/providers/modal-provider/ModalProvider.tsx
new file mode 100644
index 00000000..d1348995
--- /dev/null
+++ b/components/providers/modal-provider/ModalProvider.tsx
@@ -0,0 +1,219 @@
+/**
+ * @ Author: BetterInternship
+ * @ Create Time: 2025-12-21 02:46:39
+ * @ Modified time: 2026-03-05 16:48:33
+ * @ Description:
+ */
+
+"use client";
+
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+import { createPortal } from "react-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import { cn } from "@/lib/utils";
+
+/**
+ * A modal instance represents a modal in the registry of the global modal context.
+ */
+interface ModalInstance {
+ node: React.ReactNode;
+ opts: ModalOptions;
+}
+type ModalInstanceOpener = (
+ name: string,
+ layout: React.FC,
+ content: React.ReactNode,
+ opts?: ModalOptions,
+) => void;
+type ModalInstanceCloser = (name?: string) => void;
+
+/**
+ * These are options used to configure each modal instance.
+ */
+interface ModalOptions {
+ // Allow exiting by clicking outside modal; default: true
+ closeOnBackdropClick?: boolean;
+
+ // Allow exiting by usign escape key; default true
+ closeOnEscapeKey?: boolean; // default: true
+
+ // Show a close (X) button; default: true
+ showCloseButton?: boolean;
+
+ // Called AFTER the modal is unmounted
+ onClose?: () => void;
+ onRequestClose?: () => void;
+
+ // ! Hmm this should not be done this way
+ panelClassName?: string;
+ title?: React.ReactNode;
+ showHeaderDivider?: boolean;
+ mobileFullscreen?: boolean;
+}
+
+/**
+ * Injected modal parameters.
+ * These must be defined by every modal component to be compatible with the global modal system.
+ */
+export interface ModalInjectedParams {
+ name: string;
+ children: React.ReactNode;
+}
+
+/**
+ * The context available to each modal instance.
+ */
+export interface ModalContext {
+ openModal: ModalInstanceOpener;
+ closeModal: ModalInstanceCloser;
+}
+
+const ModalCtx = createContext({} as ModalContext);
+
+export const useGlobalModal = () => useContext(ModalCtx);
+
+export function ModalProvider({ children }: { children: React.ReactNode }) {
+ const [registry, setRegistry] = useState>({});
+
+ const openModal = useCallback(
+ (name, Layout, content, opts = {}) => {
+ setRegistry((m) => ({
+ ...m,
+ [name]: {
+ node: (
+
+ {content}
+
+ ),
+ opts,
+ },
+ }));
+ },
+ [],
+ );
+
+ const closeModal = useCallback((name) => {
+ setRegistry((m) => {
+ if (!name) {
+ Object.values(m).forEach((e) => e.opts.onClose?.());
+ return {};
+ }
+
+ const entry = m[name];
+ entry?.opts.onClose?.();
+ const { [name]: _removed, ...rest } = m;
+ return rest;
+ });
+ }, []);
+
+ // Body scroll lock + iOS --vh fix when ANY modal is open
+ useEffect(() => {
+ const count = Object.keys(registry).length;
+ if (count === 0) return;
+
+ const originalOverflow = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+
+ const setVH = () =>
+ document.documentElement.style.setProperty(
+ "--vh",
+ `${window.innerHeight * 0.01}px`,
+ );
+
+ setVH();
+ window.addEventListener("resize", setVH);
+ window.addEventListener("orientationchange", setVH);
+
+ return () => {
+ document.body.style.overflow = originalOverflow;
+ document.documentElement.style.removeProperty("--vh");
+ window.removeEventListener("resize", setVH);
+ window.removeEventListener("orientationchange", setVH);
+ };
+ }, [registry]);
+
+ // ESC to close the top-most modal that allows it
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ const entries = Object.entries(registry);
+ if (!entries.length) return;
+ const [lastName, lastEntry] = entries[entries.length - 1];
+ if (lastEntry.opts.closeOnEscapeKey !== false) closeModal(lastName);
+ };
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [registry, closeModal]);
+
+ const portals = useMemo(() => {
+ if (typeof document === "undefined") return null;
+
+ const entries = Object.entries(registry);
+ if (!entries.length) return null;
+
+ return createPortal(
+
+ {entries.map(([name, { node, opts }]) => {
+ const backdropRef = React.createRef();
+
+ return (
+ {
+ if (opts.closeOnBackdropClick === false) return;
+ if (e.target === backdropRef.current) closeModal(name);
+ }}
+ onTouchEnd={(e) => {
+ if (opts.closeOnBackdropClick === false) return;
+ if (e.target === backdropRef.current) closeModal(name);
+ }}
+ >
+ {node}
+
+ );
+ })}
+ ,
+ document.body,
+ );
+ }, [registry, closeModal]);
+
+ return (
+
+ {children}
+ {portals}
+
+ );
+}
diff --git a/components/providers/sign.ctx.tsx b/components/providers/sign.ctx.tsx
index 9db67ec8..846c1aaa 100644
--- a/components/providers/sign.ctx.tsx
+++ b/components/providers/sign.ctx.tsx
@@ -29,6 +29,16 @@ const SignContext = createContext({} as ISignContext);
export const useSignContext = () => useContext(SignContext);
+export const SignContextBridge = ({
+ value,
+ children,
+}: {
+ value: ISignContext;
+ children: React.ReactNode;
+}) => {
+ return {children} ;
+};
+
export const SignContextProvider = ({
children,
}: {
diff --git a/components/shared/header.tsx b/components/shared/header.tsx
index a049bd60..911c18a0 100644
--- a/components/shared/header.tsx
+++ b/components/shared/header.tsx
@@ -19,14 +19,11 @@ export const HeaderTitle = () => {
className="block outline-none focus:outline-none border-none"
>
BetterInternship
diff --git a/components/shared/jobs.tsx b/components/shared/jobs.tsx
index b3687baf..328c0ba6 100644
--- a/components/shared/jobs.tsx
+++ b/components/shared/jobs.tsx
@@ -2,7 +2,7 @@ import { Badge, BoolBadge } from "@/components/ui/badge";
import { Job } from "@/lib/db/db.types";
import { useDbMoa } from "@/lib/db/use-bi-moa";
import { useDbRefs } from "@/lib/db/use-refs";
-import { cn } from "@/lib/utils";
+import { cn, formatCurrency } from "@/lib/utils";
import {
AlertTriangle,
Building,
@@ -21,6 +21,7 @@ import { Property } from "../ui/labels";
import { Toggle } from "../ui/toggle";
import { useMobile } from "@/hooks/use-mobile";
import { useAppContext } from "@/lib/ctx-app";
+import { useAuthContext } from "@/lib/ctx-auth";
export const JobHead = ({
title,
@@ -438,7 +439,7 @@ export const JobDetailsSummary = ({ job }: { job: Job }) => {
{workModes ? (
-
+
@@ -452,7 +453,7 @@ export const JobDetailsSummary = ({ job }: { job: Job }) => {
)}
{workLoads ? (
-
+
@@ -466,22 +467,21 @@ export const JobDetailsSummary = ({ job }: { job: Job }) => {
)}
{job.allowance === 0 ? (
-
+
Allowance:
- {job.salary ?
₱ : <>>}
@@ -493,7 +493,7 @@ export const JobDetailsSummary = ({ job }: { job: Job }) => {
)}
{internshipTypes ? (
-
+
@@ -1016,6 +1016,7 @@ export function JobDetails({
user,
actions = [],
applyDisabledText = "Complete required items to apply.",
+ isAuthenticated,
}: {
job: Job;
user?: {
@@ -1024,6 +1025,7 @@ export function JobDetails({
};
actions?: React.ReactNode[];
applyDisabledText?: string;
+ isAuthenticated: boolean;
}) {
const hasGithub = !!user?.github_link?.trim();
const hasPortfolio = !!user?.portfolio_link?.trim();
@@ -1039,7 +1041,7 @@ export function JobDetails({
return (
<>
- {user !== undefined && (
+ {isAuthenticated && (
) {
- return
+ ...props
+}: React.ComponentProps) {
+ return ;
}
function AccordionItem({
- className,
- ...props
- }: React.ComponentProps) {
- return (
-
- )
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
}
function AccordionTrigger({
- className,
- children,
- ...props
- }: React.ComponentProps) {
- return (
-
- svg]:rotate-180",
- className
- )}
- {...props}
- >
- {children}
-
-
-
- )
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ );
}
function AccordionContent({
- className,
- children,
- ...props
- }: React.ComponentProps) {
- return (
-
- {children}
-
- )
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
}
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/components/ui/timeline.tsx b/components/ui/timeline.tsx
index f1bcbff2..6ce4cbec 100644
--- a/components/ui/timeline.tsx
+++ b/components/ui/timeline.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { CheckIcon } from "lucide-react";
+import { CheckIcon, MailWarningIcon } from "lucide-react";
export const Timeline = ({ children }: { children: React.ReactNode }) => {
return {children}
;
@@ -11,6 +11,7 @@ interface TimelineItemProps {
subtitle?: React.ReactNode;
isLast?: boolean;
children?: React.ReactNode;
+ fromMe?: boolean;
}
export const TimelineItem = ({
@@ -19,33 +20,40 @@ export const TimelineItem = ({
subtitle,
isLast = false,
children,
+ fromMe,
}: TimelineItemProps) => {
const isCheckmark = number === -1;
+ const isExclamation = fromMe;
return (
-
- {/* Timeline connector */}
-
- {/* Circle or Checkmark */}
+
+
{isCheckmark ? (
- ) : (
+ ) : isExclamation ? (
+
+
+ ) : (
+
{number}
)}
- {/* Line to next item */}
- {!isLast &&
}
+ {!isLast &&
}
{/* Content */}
-
-
-
{title}
+
+
+ {fromMe ? (
+
{title}
+ ) : (
+
{title}
+ )}
{subtitle && (
-
{subtitle}
+
{subtitle}
)}
{children &&
{children}
}
diff --git a/hooks/base/useStateRecord.tsx b/hooks/base/useStateRecord.tsx
new file mode 100644
index 00000000..ba25611e
--- /dev/null
+++ b/hooks/base/useStateRecord.tsx
@@ -0,0 +1,57 @@
+/**
+ * @ Author: BetterInternship
+ * @ Create Time: 2026-03-04 14:26:13
+ * @ Modified time: 2026-03-06 13:00:11
+ * @ Description:
+ *
+ * This seems to be a very common use case.
+ *
+ */
+
+import { useState } from "react";
+
+export type StateRecord
= Record;
+
+export interface StateRecordActions {
+ setOne: (key: string, value: T) => void;
+ setMany: (newRecord: Record) => void;
+ clearOne: (key: string) => void;
+ clearAll: () => void;
+ overwrite: (newRecord: Record) => void;
+}
+
+export const useStateRecord = (
+ initialState: Record,
+): [Record, StateRecordActions] => {
+ const [record, setRecord] = useState>(initialState);
+
+ const setOne = (key: string, value: T) => {
+ setRecord({
+ ...record,
+ [key]: value,
+ });
+ };
+
+ const setMany = (newRecord: Record) => {
+ setRecord({
+ ...record,
+ ...newRecord,
+ });
+ };
+
+ const clearOne = (key: string) => {
+ const recordBase = { ...record };
+ delete recordBase[key];
+ setRecord(recordBase);
+ };
+
+ const clearAll = () => {
+ setRecord({});
+ };
+
+ const overwrite = (newRecord: Record) => {
+ setRecord(newRecord);
+ };
+
+ return [record, { setOne, setMany, clearOne, clearAll, overwrite }] as const;
+};
diff --git a/hooks/forms/filloutFormProcess.tsx b/hooks/forms/filloutFormProcess.tsx
new file mode 100644
index 00000000..661a20fb
--- /dev/null
+++ b/hooks/forms/filloutFormProcess.tsx
@@ -0,0 +1,101 @@
+/**
+ * @ Author: BetterInternship
+ * @ Create Time: 2026-03-04 16:35:00
+ * @ Modified time: 2026-03-04 16:46:09
+ * @ Description:
+ *
+ * Client process implementation for the form fillout process
+ */
+
+import { useMyForms } from "@/app/student/forms/myforms.ctx";
+import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx";
+import { FormService } from "@/lib/api/services";
+import { useClientProcess } from "@betterinternship/components";
+import { useCallback, useMemo } from "react";
+import { toast } from "sonner";
+
+const formFilloutKey = "form-fillout";
+
+interface FilloutFormProcessResult {
+ formId: string;
+ formProcessId: string;
+ downloadUrl: string;
+}
+
+export const useFormFilloutProcessRunner = () => {
+ const myForms = useMyForms();
+ const form = useFormRendererContext();
+
+ return useClientProcess({
+ filterKey: formFilloutKey,
+ caller: FormService.filloutForm.bind(FormService),
+ invalidator: useCallback(
+ (result: FilloutFormProcessResult) => {
+ return myForms.forms.some(
+ (form) => form.form_process_id === result.formProcessId,
+ );
+ },
+ [myForms.forms],
+ ),
+ onSuccess: (processId, _processName, result) => {
+ toast.success(`Generated ${form.formLabel}!`, {
+ id: processId,
+ duration: 2000,
+ });
+ console.log("FILLOUT FORM RESULT: ", result);
+ },
+ onFailure: (processId, _processName, error) => {
+ toast.error(`Could not generate ${form.formLabel}: ${error}`, {
+ id: processId,
+ duration: 2000,
+ });
+ console.log("FILLOUT FORM ERROR: ", error);
+ },
+ });
+};
+
+export const useFormFilloutProcessReader = () => {
+ return useClientProcess({ filterKey: formFilloutKey });
+};
+
+export const useFormFilloutProcessPending = () => {
+ const myForms = useMyForms();
+ const formFilloutProcessReader = useFormFilloutProcessReader();
+
+ return useMemo(
+ () =>
+ formFilloutProcessReader.getAllPending().map((pendingForm) => ({
+ label: pendingForm.metadata?.metadata?.label ?? "",
+ timestamp: pendingForm.metadata?.metadata?.timestamp ?? "",
+ pending: true,
+ })),
+ [myForms.forms],
+ );
+};
+
+export const useFormFilloutProcessHandled = () => {
+ const myForms = useMyForms();
+ const formFilloutProcessReader = useFormFilloutProcessReader();
+
+ return useMemo(
+ () =>
+ formFilloutProcessReader
+ .getAllHandled()
+ .filter((handledForm) =>
+ myForms.forms.every(
+ (form) =>
+ form.form_process_id !==
+ (handledForm.result as FilloutFormProcessResult).formProcessId,
+ ),
+ )
+ .map((handledForm) => ({
+ label: handledForm.metadata?.metadata?.label ?? "",
+ timestamp: handledForm.metadata?.metadata?.timestamp ?? "",
+ downloadUrl: (handledForm.result as FilloutFormProcessResult)
+ .downloadUrl,
+ pending: false,
+ status: "done",
+ })),
+ [myForms.forms],
+ );
+};
diff --git a/hooks/use-modal.tsx b/hooks/use-modal.tsx
index 2ea9a6ef..cf4a4179 100644
--- a/hooks/use-modal.tsx
+++ b/hooks/use-modal.tsx
@@ -30,21 +30,35 @@ import { useMobile } from "./use-mobile"; // touch helpers
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
-/** Exposed for optional imperative usage */
+/**
+ * Old modal handle.
+ *
+ * @param name
+ * @param options
+ * @deprecated use useGlobalModal() instead.
+ * @returns
+ */
export type ModalHandle = { open: () => void; close: () => void };
-/** The main hook */
+/**
+ * Old modal impl.
+ *
+ * @param name
+ * @param options
+ * @deprecated use useGlobalModal() instead.
+ * @returns
+ */
export const useModal = (
name: string,
options?: {
onClose?: () => void;
showCloseButton?: boolean;
- allowBackdropClick?: boolean;
- }
+ closeOnBackdropClick?: boolean;
+ },
) => {
const [isOpen, setIsOpen] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
- const { showCloseButton = true, allowBackdropClick = true } = options || {};
+ const { showCloseButton = true, closeOnBackdropClick = true } = options || {};
const { isMobile } = useAppContext();
const { isTouchOnSingleElement, isTouchEndOnElement, isSwipe } = useMobile();
@@ -60,14 +74,14 @@ export const useModal = (
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
- if (!allowBackdropClick) return;
+ if (!closeOnBackdropClick) return;
if (isMobile) return;
if (e.target === backdropRef.current) {
console.debug(`[useModal:${name}] backdrop click -> close`);
setIsOpen(false);
}
},
- [isMobile, name, allowBackdropClick]
+ [isMobile, name, closeOnBackdropClick],
);
// Lock body scroll + iOS --vh fix when open
@@ -82,7 +96,7 @@ export const useModal = (
const setVH = () => {
document.documentElement.style.setProperty(
"--vh",
- `${window.innerHeight * 0.01}px`
+ `${window.innerHeight * 0.01}px`,
);
};
setVH();
@@ -127,11 +141,9 @@ export const useModal = (
const Modal = ({
children,
className,
- backdropClassName,
}: {
children?: React.ReactNode;
- className?: string; // e.g. "max-w-7xl w-full"
- backdropClassName?: string;
+ className?: string;
}) => {
useEffect(() => {
if (!isOpen) setHasMounted(isOpen);
@@ -146,7 +158,6 @@ export const useModal = (
isMobile
? "items-end justify-center p-0"
: "items-center justify-center p-4",
- backdropClassName
)}
onClick={handleBackdropClick}
onTouchEnd={() => {
@@ -181,7 +192,7 @@ export const useModal = (
isMobile
? "max-w-full min-w-[100svw] mx-0 rounded-t-md rounded-b-none min-h-[200px]"
: "max-w-2xl rounded-md",
- className
+ className,
)}
>
{showCloseButton && (
@@ -200,7 +211,7 @@ export const useModal = (
}}
className={cn(
"h-8 w-8 p-0 hover:bg-gray-100 rounded-full transition-colors",
- isMobile && "active:bg-gray-200"
+ isMobile && "active:bg-gray-200",
)}
aria-label="Close modal"
>
@@ -213,7 +224,7 @@ export const useModal = (
{children}
@@ -241,7 +252,7 @@ const ModalTemplate = (
}: {
content?: React.ReactNode;
onClose?: () => void;
- }
+ },
) => {
const { open, close, Modal } = useModal(name, { onClose });
return forwardRef<
@@ -249,7 +260,6 @@ const ModalTemplate = (
{
children?: React.ReactNode;
className?: string;
- backdropClassName?: string;
}
>((props, ref) => {
useImperativeHandle(ref, () => ({ open, close }));
@@ -257,6 +267,13 @@ const ModalTemplate = (
});
};
+/**
+ * Old modal component.
+ *
+ * @deprecated use useGlobalModal() instead.
+ * @param
+ * @returns
+ */
export const ModalComponent = ({
children,
ref,
@@ -268,4 +285,11 @@ export const ModalComponent = ({
return
;
};
+/**
+ * Old modal component.
+ *
+ * @deprecated use useGlobalModal() instead.
+ * @param
+ * @returns
+ */
export const useModalRef = () => useRef
(null);
diff --git a/hooks/use-side-modal.tsx b/hooks/use-side-modal.tsx
index 43c5e948..5e9eab30 100644
--- a/hooks/use-side-modal.tsx
+++ b/hooks/use-side-modal.tsx
@@ -10,26 +10,24 @@ import { useAppContext } from "@/lib/ctx-app";
*/
export const useSideModal = (
name: string,
- options?: { onClose?: () => void,
- allowBackdropClick?: boolean
- }
+ options?: { onClose?: () => void; closeOnBackdropClick?: boolean },
) => {
const [isOpen, setIsOpen] = useState(false);
- const { allowBackdropClick = true } = options || {};
+ const { closeOnBackdropClick = true } = options || {};
const { isMobile } = useAppContext();
const backdropRef = useRef(null);
const handleBackdropClick = useCallback(
- (e: React.MouseEvent) => {
- if (!allowBackdropClick) return;
- if (isMobile) return;
- if (e.target === backdropRef.current) {
- console.debug(`[useModal:${name}] backdrop click -> close`);
- setIsOpen(false);
- }
- },
- [isMobile, name, allowBackdropClick]
- );
+ (e: React.MouseEvent) => {
+ if (!closeOnBackdropClick) return;
+ if (isMobile) return;
+ if (e.target === backdropRef.current) {
+ console.debug(`[useModal:${name}] backdrop click -> close`);
+ setIsOpen(false);
+ }
+ },
+ [isMobile, name, closeOnBackdropClick],
+ );
return {
open: () => setIsOpen(true),
@@ -37,10 +35,10 @@ export const useSideModal = (
SideModal: ({ children }: { children: React.ReactNode }) => {
return (
isOpen && (
-
@@ -48,7 +46,8 @@ export const useSideModal = (
variant="ghost"
size="sm"
onClick={() => (
- setIsOpen(false), options?.onClose && options?.onClose()
+ setIsOpen(false),
+ options?.onClose && options?.onClose()
)}
className="p-2 hover:bg-gray-100 rounded-full"
>
@@ -62,4 +61,4 @@ export const useSideModal = (
);
},
};
-};
\ No newline at end of file
+};
diff --git a/lib/api/api-client.ts b/lib/api/api-client.ts
index af4a6f57..91e861ed 100644
--- a/lib/api/api-client.ts
+++ b/lib/api/api-client.ts
@@ -86,6 +86,7 @@ class FetchClient {
const errorData = await response.json().catch(() => ({}));
console.warn(`${url}: ${errorData.message || response.status}`);
return { error: errorData.message } as T;
+ // throw new Error(errorData.message || "Something went wrong.");
}
const contentType = response.headers.get("content-type");
diff --git a/lib/api/services.ts b/lib/api/services.ts
index 6aef87f3..fe86d1eb 100644
--- a/lib/api/services.ts
+++ b/lib/api/services.ts
@@ -12,12 +12,24 @@ import {
import { APIClient, APIRouteBuilder } from "./api-client";
import { FetchResponse } from "@/lib/api/use-fetch";
import { IFormMetadata, IFormSigningParty } from "@betterinternship/core/forms";
-import { Tables } from "@betterinternship/schema.base";
interface EmployerResponse extends FetchResponse {
employer: Partial
;
}
+export interface ProcessCallbackDto {
+ processId: string;
+ processName: string;
+}
+
+export interface ProcessResponse {
+ processId: string;
+ processName: string;
+ processCallbackUrl: string;
+ success: boolean;
+ message?: string;
+}
+
export const EmployerService = {
async getMyProfile() {
return APIClient.get(
@@ -180,13 +192,10 @@ export const FormService = {
values: Record;
disableEsign?: boolean;
}) {
- return APIClient.post<{
- formProcessId: string;
- documentId?: string;
- documentUrl?: string;
- success?: boolean;
- message?: string;
- }>(APIRouteBuilder("users").r("me/fillout-form").build(), data);
+ return APIClient.post(
+ APIRouteBuilder("users").r("me/fillout-form").build(),
+ data,
+ );
},
async getMyFormTemplates() {
@@ -227,7 +236,7 @@ export const FormService = {
async getForm(formName: string) {
const form = await APIClient.get<
{
- formDocument: {
+ formTemplate: {
name: string;
label: string;
version: number;
@@ -332,7 +341,6 @@ export const UserService = {
APIRouteBuilder("users").r(userId).build(),
);
},
-
};
// Job Services
diff --git a/lib/api/student.actions.api.ts b/lib/api/student.actions.api.ts
index 5a0d4334..9a48b7a5 100644
--- a/lib/api/student.actions.api.ts
+++ b/lib/api/student.actions.api.ts
@@ -67,8 +67,10 @@ export const useProfileActions = () => {
const actions = {
update: useMutation({
mutationFn: UserService.updateMyProfile,
- onSettled: () =>
- queryClient.invalidateQueries({ queryKey: ["my-profile"] }),
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: ["my-profile"] });
+ queryClient.invalidateQueries({ queryKey: ["my-form-templates"] });
+ }
}),
};
diff --git a/lib/form-previewer-model.ts b/lib/form-previewer-model.ts
new file mode 100644
index 00000000..523909b3
--- /dev/null
+++ b/lib/form-previewer-model.ts
@@ -0,0 +1,287 @@
+import { coerceAnyDate, formatTimestampDateWithoutTime } from "@/lib/utils";
+
+export type PreviewFieldType = "text" | "signature" | "image";
+
+export interface PreviewField {
+ id: string;
+ field: string;
+ label: string;
+ page: number;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ size?: number;
+ wrap?: boolean;
+ align_h?: "left" | "center" | "right";
+ align_v?: "top" | "middle" | "bottom";
+ font?: string;
+ type?: PreviewFieldType;
+ signing_party_id?: string;
+ source?: string;
+ prefiller?: unknown;
+}
+
+// Incoming student preview payload shape.
+export type PreviewFieldInput = {
+ _id?: string;
+ id?: string;
+ field: string;
+ label?: string;
+ page?: number;
+ x?: number;
+ y?: number;
+ w?: number;
+ h?: number;
+ size?: number;
+ wrap?: boolean;
+ align_h?: "left" | "center" | "right";
+ align_v?: "top" | "middle" | "bottom";
+ font?: string;
+ type?: PreviewFieldType;
+ signing_party_id?: string;
+ source?: string;
+ prefiller?: unknown;
+};
+
+export function toPreviewFields(inputs: PreviewFieldInput[]): PreviewField[] {
+ return (inputs ?? [])
+ .filter((input) => input?.field && input.type !== "image")
+ .map((input, idx) => {
+ const page = typeof input.page === "number" ? input.page : 1;
+ const x = typeof input.x === "number" ? input.x : 0;
+ const y = typeof input.y === "number" ? input.y : 0;
+ const w = typeof input.w === "number" ? input.w : 0;
+ const h = typeof input.h === "number" ? input.h : 0;
+ const id =
+ (typeof input.id === "string" && input.id) ||
+ (typeof input._id === "string" && input._id) ||
+ `${input.field}:${page}:${x}:${y}:${idx}`;
+
+ return {
+ id,
+ field: input.field,
+ label: input.label || input.field,
+ page,
+ x,
+ y,
+ w,
+ h,
+ size: input.size,
+ wrap: input.wrap,
+ align_h: input.align_h,
+ align_v: input.align_v,
+ font: input.font,
+ type: input.type,
+ signing_party_id: input.signing_party_id,
+ source: input.source,
+ prefiller: input.prefiller,
+ };
+ });
+}
+
+export function groupFieldsByPage(fields: PreviewField[]) {
+ const byPage = new Map();
+
+ for (const field of fields) {
+ const list = byPage.get(field.page) || [];
+ list.push(field);
+ byPage.set(field.page, list);
+ }
+
+ return byPage;
+}
+type PreviewRefRecord = { name?: string } | null;
+
+export interface PreviewValueRefs {
+ get_college?: (id: string | null | undefined) => PreviewRefRecord;
+ get_department?: (id: string | null | undefined) => PreviewRefRecord;
+ get_university?: (id: string | null | undefined) => PreviewRefRecord;
+ to_college_name?: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ to_department_name?: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+ to_university_name?: (
+ id: string | null | undefined,
+ def?: string | null,
+ ) => string | null;
+}
+
+export const normalizePreviewFieldKey = (fieldKey: string): string =>
+ String(fieldKey ?? "")
+ .trim()
+ .replace(/:default$/i, "");
+
+export const resolveAutoPreviewValue = (
+ fieldKey: string,
+ now = new Date(),
+): string => {
+ const normalized = normalizePreviewFieldKey(fieldKey).toLowerCase();
+ if (normalized === "auto.current-date")
+ return formatTimestampDateWithoutTime(now.getTime());
+ if (normalized === "auto.current-day") return now.getDate().toString();
+ if (normalized === "auto.current-month")
+ return (now.getMonth() + 1).toString();
+ if (normalized === "auto.current-year") return now.getFullYear().toString();
+ return "";
+};
+
+export const createPreviewDisplayValueResolver = ({
+ refs,
+ user,
+ nowFactory = () => new Date(),
+}: {
+ refs: PreviewValueRefs;
+ user: Record | null | undefined;
+ nowFactory?: () => Date;
+}) => {
+ const getUserString = (key: string): string => {
+ const value = user?.[key];
+ return value == null ? "" : String(value).trim();
+ };
+
+ const resolvePrefillValue = (field: PreviewField): string => {
+ if (!user) return "";
+
+ const firstName = getUserString("first_name");
+ const middleName = getUserString("middle_name");
+ const lastName = getUserString("last_name");
+ const fullName = [firstName, middleName, lastName]
+ .filter(Boolean)
+ .join(" ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ const normalizedField = normalizePreviewFieldKey(field.field).toLowerCase();
+ const directMap: Record = {
+ "student.school": getUserString("college"),
+ "student.college": getUserString("college"),
+ "student.department": getUserString("department"),
+ "student.university": getUserString("university"),
+ "student.first-name": firstName,
+ "student.middle-name": middleName,
+ "student.last-name": lastName,
+ "student.full-name": fullName,
+ "student-signature": fullName,
+ "student.phone-number": getUserString("phone_number"),
+ "student.email": getUserString("email"),
+ };
+ const mapped = directMap[normalizedField];
+ if (mapped) return mapped;
+
+ if (typeof field.prefiller === "function") {
+ try {
+ const prefiller = field.prefiller as (params: {
+ user: Record | null | undefined;
+ }) => unknown;
+ const value = prefiller({ user });
+ return value == null ? "" : String(value).trim();
+ } catch {
+ return "";
+ }
+ }
+
+ if (typeof field.prefiller === "string") {
+ const match = field.prefiller.match(/user\.([a-zA-Z0-9_]+)/);
+ if (match?.[1]) return getUserString(match[1]);
+ }
+
+ return "";
+ };
+
+ const resolveInitiatorFallbackValue = (field: PreviewField): string => {
+ const owner = String(field.signing_party_id ?? "").toLowerCase();
+ const isInitiatorOwned = owner === "initiator" || owner === "student";
+ if (!isInitiatorOwned) return "";
+
+ const source = String(field.source ?? "").toLowerCase();
+ const normalizedField = normalizePreviewFieldKey(field.field).toLowerCase();
+ const shouldUseAuto =
+ source === "auto" || normalizedField.startsWith("auto.current-");
+ const shouldUsePrefill =
+ source === "prefill" ||
+ normalizedField === "student.school" ||
+ normalizedField === "student.college" ||
+ normalizedField === "student.department" ||
+ normalizedField === "student.university" ||
+ normalizedField === "student.first-name" ||
+ normalizedField === "student.middle-name" ||
+ normalizedField === "student.last-name" ||
+ normalizedField === "student.full-name" ||
+ normalizedField === "student-signature" ||
+ normalizedField === "student.phone-number" ||
+ normalizedField === "student.email";
+
+ if (shouldUseAuto)
+ return resolveAutoPreviewValue(field.field, nowFactory());
+ if (shouldUsePrefill) return resolvePrefillValue(field);
+ return "";
+ };
+
+ const tryResolveRefName = (candidate: string): string | null => {
+ const college = refs.get_college?.(candidate)?.name;
+ if (college) return college;
+ const department = refs.get_department?.(candidate)?.name;
+ if (department) return department;
+ const university = refs.get_university?.(candidate)?.name;
+ if (university) return university;
+ return null;
+ };
+
+ return (field: PreviewField, rawValue: unknown): string => {
+ const rawString = Array.isArray(rawValue)
+ ? rawValue.join(", ")
+ : typeof rawValue === "string"
+ ? rawValue
+ : typeof rawValue === "number"
+ ? String(rawValue)
+ : "";
+ const fallbackValue = rawString.trim()
+ ? ""
+ : resolveInitiatorFallbackValue(field);
+ const value = rawString || fallbackValue;
+ if (!value) return "";
+
+ const trimmedValue = value.trim();
+ if (!trimmedValue) return "";
+
+ const normalizedField = normalizePreviewFieldKey(field.field).toLowerCase();
+ if (normalizedField === "auto.current-date") {
+ const dateMs = coerceAnyDate(trimmedValue);
+ if (dateMs) return formatTimestampDateWithoutTime(dateMs);
+ }
+
+ const loweredFieldName = field.field.toLowerCase();
+ if (
+ loweredFieldName.includes("college") &&
+ typeof refs.to_college_name === "function"
+ ) {
+ return refs.to_college_name(trimmedValue, trimmedValue) ?? trimmedValue;
+ }
+ if (
+ loweredFieldName.includes("department") &&
+ typeof refs.to_department_name === "function"
+ ) {
+ return (
+ refs.to_department_name(trimmedValue, trimmedValue) ?? trimmedValue
+ );
+ }
+ if (
+ loweredFieldName.includes("university") &&
+ typeof refs.to_university_name === "function"
+ ) {
+ return (
+ refs.to_university_name(trimmedValue, trimmedValue) ?? trimmedValue
+ );
+ }
+
+ const directRefMatch = tryResolveRefName(trimmedValue);
+ if (directRefMatch) return directRefMatch;
+
+ return value;
+ };
+};
diff --git a/lib/form-previewer-rendering.ts b/lib/form-previewer-rendering.ts
new file mode 100644
index 00000000..47f46f34
--- /dev/null
+++ b/lib/form-previewer-rendering.ts
@@ -0,0 +1,459 @@
+import type { PreviewFieldType } from "@/lib/form-previewer-model";
+
+const DEFAULT_TEXT_FONT_FILE = "Roboto-Regular.ttf";
+const DEFAULT_SIGNATURE_FONT_FILE = "megastina.regular.ttf";
+const PREVIEW_FONT_STYLE_ID = "previewer-font-face-style";
+const PREVIEW_GOOGLE_FONT_LINK_ID = "previewer-google-font-link";
+
+const LOCAL_PREVIEW_FONTS = [
+ { family: "MegastinaPreview", file: "megastina.regular.ttf" },
+ { family: "HighEmpathyPreview", file: "high-empathy.regular.ttf" },
+ { family: "RoyaltyFreePreview", file: "royalty-free.regular.ttf" },
+ { family: "TestimoniaPreview", file: "Testimonia.ttf" },
+ { family: "TheSignaturePreview", file: "thesignature.regular.ttf" },
+ { family: "ItaliannoPreview", file: "Italianno-Regular.ttf" },
+ { family: "RobotoPreview", file: "Roboto-Regular.ttf" },
+] as const;
+
+export type ResolvedPreviewFont = {
+ cssFamily: string;
+ canvasFamily: string;
+ fontWeight: "400";
+};
+
+const normalizeFontToken = (value?: string | null) =>
+ String(value ?? "")
+ .trim()
+ .toLowerCase();
+
+export const resolvePreviewFont = (
+ fieldType?: PreviewFieldType,
+ fieldFont?: string | null,
+): ResolvedPreviewFont => {
+ const fallbackToken =
+ fieldType === "signature"
+ ? DEFAULT_SIGNATURE_FONT_FILE
+ : DEFAULT_TEXT_FONT_FILE;
+ const token = normalizeFontToken(fieldFont || fallbackToken);
+ const isSignatureField = fieldType === "signature";
+
+ if (token.includes("megastina")) {
+ return {
+ cssFamily: "MegastinaPreview, cursive",
+ canvasFamily: "MegastinaPreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("high empathy") || token.includes("high-empathy")) {
+ return {
+ cssFamily: "HighEmpathyPreview, cursive",
+ canvasFamily: "HighEmpathyPreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("royalty free") || token.includes("royalty-free")) {
+ return {
+ cssFamily: "RoyaltyFreePreview, cursive",
+ canvasFamily: "RoyaltyFreePreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("testimonia")) {
+ return {
+ cssFamily: "TestimoniaPreview, cursive",
+ canvasFamily: "TestimoniaPreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("the signature") || token.includes("thesignature")) {
+ return {
+ cssFamily: "TheSignaturePreview, cursive",
+ canvasFamily: "TheSignaturePreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ // Signature fields should always use a signature-style font family.
+ // If metadata carries a generic text font token (e.g. Roboto), use Megastina fallback.
+ if (isSignatureField) {
+ return {
+ cssFamily: "MegastinaPreview, cursive",
+ canvasFamily: "MegastinaPreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("italianno")) {
+ return {
+ cssFamily: "ItaliannoPreview, cursive",
+ canvasFamily: "ItaliannoPreview, cursive",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("roboto")) {
+ return {
+ cssFamily: "RobotoPreview, Roboto, sans-serif",
+ canvasFamily: "RobotoPreview, Roboto, sans-serif",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("arial")) {
+ return {
+ cssFamily: "Arial, sans-serif",
+ canvasFamily: "Arial, sans-serif",
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("times")) {
+ return {
+ cssFamily: '"Times New Roman", serif',
+ canvasFamily: '"Times New Roman", serif',
+ fontWeight: "400",
+ };
+ }
+
+ if (token.includes("ubuntu mono")) {
+ return {
+ cssFamily: '"Ubuntu Mono", monospace',
+ canvasFamily: '"Ubuntu Mono", monospace',
+ fontWeight: "400",
+ };
+ }
+
+ return {
+ cssFamily: "RobotoPreview, Roboto, sans-serif",
+ canvasFamily: "RobotoPreview, Roboto, sans-serif",
+ fontWeight: "400",
+ };
+};
+
+export const ensurePreviewFontsLoaded = () => {
+ if (typeof window === "undefined") return;
+
+ if (!document.getElementById(PREVIEW_GOOGLE_FONT_LINK_ID)) {
+ const link = document.createElement("link");
+ link.id = PREVIEW_GOOGLE_FONT_LINK_ID;
+ link.href =
+ "https://fonts.googleapis.com/css2?family=Roboto:wght@400&family=Italianno&display=block";
+ link.rel = "stylesheet";
+ document.head.appendChild(link);
+ }
+
+ if (!document.getElementById(PREVIEW_FONT_STYLE_ID)) {
+ const style = document.createElement("style");
+ style.id = PREVIEW_FONT_STYLE_ID;
+ style.textContent = LOCAL_PREVIEW_FONTS.map(
+ ({ family, file }) => `
+@font-face {
+ font-family: "${family}";
+ src: url("/fonts/${file}") format("truetype");
+ font-weight: 400;
+ font-style: normal;
+ font-display: block;
+}`,
+ ).join("\n");
+ document.head.appendChild(style);
+ }
+
+ if ("fonts" in document) {
+ document.fonts.ready.catch(() => {
+ // Font loading failure should not block preview rendering.
+ });
+ }
+};
+
+let sharedCanvas: HTMLCanvasElement | null = null;
+let sharedCtx: CanvasRenderingContext2D | null = null;
+
+const fitNoWrapCache = new Map<
+ string,
+ {
+ fontSize: number;
+ line: string;
+ ascent: number;
+ descent: number;
+ height: number;
+ }
+>();
+
+function getSharedContext(): CanvasRenderingContext2D | null {
+ if (!sharedCanvas) {
+ sharedCanvas = document.createElement("canvas");
+ sharedCtx = sharedCanvas.getContext("2d");
+ }
+ return sharedCtx;
+}
+
+function measureTextWidth(
+ text: string,
+ fontSize: number,
+ fontFamily: string,
+): number {
+ const ctx = getSharedContext();
+ if (!ctx) return 0;
+ ctx.font = `${fontSize}px ${fontFamily}`;
+ return ctx.measureText(text).width;
+}
+
+function getFontMetricsAtSize(fontSize: number, fontFamily: string) {
+ const ctx = getSharedContext();
+ if (ctx) {
+ ctx.font = `${fontSize}px ${fontFamily}`;
+ const metrics = ctx.measureText("Ag");
+ if (
+ Number.isFinite(metrics.actualBoundingBoxAscent) &&
+ Number.isFinite(metrics.actualBoundingBoxDescent)
+ ) {
+ const ascent = metrics.actualBoundingBoxAscent;
+ const descent = -metrics.actualBoundingBoxDescent;
+ return {
+ ascent,
+ descent,
+ height: ascent - descent,
+ };
+ }
+ }
+
+ const ascent = fontSize * 0.8;
+ const descent = -fontSize * 0.2;
+ return {
+ ascent,
+ descent,
+ height: ascent - descent,
+ };
+}
+
+function wrapText({
+ text,
+ fontSize,
+ fontFamily,
+ maxWidth,
+ zoom = 1,
+}: {
+ text: string;
+ fontSize: number;
+ fontFamily: string;
+ maxWidth: number;
+ zoom?: number;
+}): string[] {
+ const paragraphs = String(text ?? "").split(/\r?\n/);
+ const lines: string[] = [];
+ const measure = (s: string) =>
+ measureTextWidth(s, fontSize, fontFamily) * zoom;
+
+ const breakLongWord = (word: string): string[] => {
+ const parts: string[] = [];
+ let cur = "";
+ for (const ch of word) {
+ const next = cur + ch;
+ if (cur && measure(next) > maxWidth) {
+ parts.push(cur);
+ cur = ch;
+ } else {
+ cur = next;
+ }
+ }
+ if (cur) parts.push(cur);
+ return parts;
+ };
+
+ for (const para of paragraphs) {
+ const trimmed = para.trim();
+ if (!trimmed) {
+ lines.push("");
+ continue;
+ }
+
+ const words = trimmed.split(/\s+/);
+ let current = "";
+
+ for (const w of words) {
+ const candidate = current ? `${current} ${w}` : w;
+
+ if (measure(candidate) <= maxWidth) {
+ current = candidate;
+ continue;
+ }
+
+ if (current) {
+ lines.push(current);
+ current = "";
+ }
+
+ if (measure(w) <= maxWidth) {
+ current = w;
+ } else {
+ const broken = breakLongWord(w);
+ for (let i = 0; i < broken.length; i++) {
+ if (i === broken.length - 1) current = broken[i];
+ else lines.push(broken[i]);
+ }
+ }
+ }
+
+ if (current) lines.push(current);
+ }
+
+ return lines;
+}
+
+function layoutWrappedBlock({
+ text,
+ fontSize,
+ fontFamily,
+ lineHeight,
+ maxWidth,
+ zoom = 1,
+}: {
+ text: string;
+ fontSize: number;
+ fontFamily: string;
+ lineHeight: number;
+ maxWidth: number;
+ zoom?: number;
+}) {
+ const { ascent, descent } = getFontMetricsAtSize(fontSize, fontFamily);
+ const lines = wrapText({ text, fontSize, fontFamily, maxWidth, zoom });
+ const n = lines.length;
+ const blockHeight = (n > 0 ? (n - 1) * lineHeight : 0) + (ascent - descent);
+ return { lines, ascent, descent, blockHeight };
+}
+
+export function fitWrappedText({
+ text,
+ fontFamily,
+ maxWidth,
+ maxHeight,
+ startSize,
+ lineHeightMult = 1.2,
+ zoom = 1,
+}: {
+ text: string;
+ fontFamily: string;
+ maxWidth: number;
+ maxHeight: number;
+ startSize: number;
+ lineHeightMult?: number;
+ zoom?: number;
+}) {
+ const fits = (size: number): boolean => {
+ const lh = size * lineHeightMult;
+ const { blockHeight } = layoutWrappedBlock({
+ text,
+ fontSize: size,
+ fontFamily,
+ lineHeight: lh,
+ maxWidth,
+ zoom,
+ });
+ return blockHeight <= maxHeight + 1e-6;
+ };
+
+ if (fits(startSize)) {
+ const lh = startSize * lineHeightMult;
+ const laid = layoutWrappedBlock({
+ text,
+ fontSize: startSize,
+ fontFamily,
+ lineHeight: lh,
+ maxWidth,
+ zoom,
+ });
+ return { fontSize: startSize, lineHeight: lh, ...laid };
+ }
+
+ let hi = startSize;
+ let lo = startSize;
+ while (!fits(lo)) {
+ lo /= 2;
+ if (lo < 0.1) break;
+ }
+
+ for (let i = 0; i < 22; i++) {
+ const mid = (lo + hi) / 2;
+ if (fits(mid)) lo = mid;
+ else hi = mid;
+ }
+
+ const bestSize = lo;
+ const bestLineHeight = bestSize * lineHeightMult;
+ const laid = layoutWrappedBlock({
+ text,
+ fontSize: bestSize,
+ fontFamily,
+ lineHeight: bestLineHeight,
+ maxWidth,
+ zoom,
+ });
+ return { fontSize: bestSize, lineHeight: bestLineHeight, ...laid };
+}
+
+export function fitNoWrapText({
+ text,
+ fontFamily,
+ maxWidth,
+ maxHeight,
+ startSize,
+}: {
+ text: string;
+ fontFamily: string;
+ maxWidth: number;
+ maxHeight: number;
+ startSize: number;
+}) {
+ const line = String(text ?? "").replace(/\r?\n/g, " ");
+ const cacheKey = `${line}|${fontFamily}|${maxWidth}|${maxHeight}|${startSize}`;
+ if (fitNoWrapCache.has(cacheKey)) {
+ return fitNoWrapCache.get(cacheKey)!;
+ }
+
+ const ctx = getSharedContext();
+
+ const fits = (size: number): boolean => {
+ if (!ctx) return false;
+ ctx.font = `${size}px ${fontFamily}`;
+ const w = ctx.measureText(line).width;
+ const { height } = getFontMetricsAtSize(size, fontFamily);
+ return w <= maxWidth + 1e-6 && height <= maxHeight + 1e-6;
+ };
+
+ if (fits(startSize)) {
+ const { ascent, descent, height } = getFontMetricsAtSize(
+ startSize,
+ fontFamily,
+ );
+ const result = { fontSize: startSize, line, ascent, descent, height };
+ fitNoWrapCache.set(cacheKey, result);
+ return result;
+ }
+
+ let hi = startSize;
+ let lo = startSize;
+ while (!fits(lo)) {
+ lo /= 2;
+ if (lo < 0.1) break;
+ }
+
+ for (let i = 0; i < 22; i++) {
+ const mid = (lo + hi) / 2;
+ if (fits(mid)) lo = mid;
+ else hi = mid;
+ }
+
+ const bestSize = lo;
+ const { ascent, descent, height } = getFontMetricsAtSize(
+ bestSize,
+ fontFamily,
+ );
+ const result = { fontSize: bestSize, line, ascent, descent, height };
+ fitNoWrapCache.set(cacheKey, result);
+
+ return result;
+}
diff --git a/lib/utils/num-utils.ts b/lib/utils/num-utils.ts
index 6b61dc3f..f1cce5d7 100644
--- a/lib/utils/num-utils.ts
+++ b/lib/utils/num-utils.ts
@@ -10,3 +10,20 @@ export const isValidOptionalPhoneNumber = (phone: string): boolean => {
const cleanPhone = phone.replace(/\D/g, "");
return cleanPhone.length === 11 && /^09\d{9}$/.test(cleanPhone);
};
+
+/**
+ * Formats a number as currency.
+ */
+export const formatCurrency = (
+ value: number,
+ locale = "en-PH",
+ currency = "PHP",
+) => {
+ if (value === null || Number.isNaN(value)) return "N/A";
+
+ return new Intl.NumberFormat(locale, {
+ style: "currency",
+ currency,
+ maximumFractionDigits: 2,
+ }).format(value);
+};
\ No newline at end of file
diff --git a/next.config.mjs b/next.config.mjs
index bd03819d..801485b5 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -36,7 +36,7 @@ const nextConfig = {
// Rewrite everything except _next and root-level common files
rewrites.push({
source:
- "/:path((?!_next|BetterInternshipLogo|resume-loader|PrivacyPolicy|TermsConditions|Student_MOA|Company_Information|student-preview|hire-preview|miro-preview).*)*",
+ "/:path((?!_next|fonts|BetterInternshipLogo|resume-loader|PrivacyPolicy|TermsConditions|Student_MOA|Company_Information|student-preview|hire-preview|miro-preview).*)*",
has: [{ type: "host", value: host }],
destination: `/${destination}/:path*`,
});
diff --git a/package.json b/package.json
index c95295ba..87a86fc9 100644
--- a/package.json
+++ b/package.json
@@ -12,8 +12,8 @@
"test": "npx jest --no-color --passWithNoTests >jest.log 2>&1"
},
"dependencies": {
- "@betterinternship/components": "^1.0.1",
- "@betterinternship/core": "^1.9.4",
+ "@betterinternship/components": "1.5.28",
+ "@betterinternship/core": "^2.8.10",
"@betterinternship/schema.base": "^1.2.1",
"@betterinternship/schema.moa": "^1.5.15",
"@dnd-kit/core": "^6.3.1",
@@ -21,6 +21,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^3.10.0",
"@mdxeditor/editor": "^3.35.1",
+ "@posthog/react": "^1.8.1",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-aspect-ratio": "1.1.1",
diff --git a/public/fonts/Italianno-Regular.ttf b/public/fonts/Italianno-Regular.ttf
new file mode 100644
index 00000000..ea526879
Binary files /dev/null and b/public/fonts/Italianno-Regular.ttf differ
diff --git a/public/fonts/Roboto-Regular.ttf b/public/fonts/Roboto-Regular.ttf
new file mode 100644
index 00000000..2c97eead
Binary files /dev/null and b/public/fonts/Roboto-Regular.ttf differ
diff --git a/public/fonts/Testimonia.ttf b/public/fonts/Testimonia.ttf
new file mode 100644
index 00000000..704efda2
Binary files /dev/null and b/public/fonts/Testimonia.ttf differ
diff --git a/public/fonts/high-empathy.regular.ttf b/public/fonts/high-empathy.regular.ttf
new file mode 100644
index 00000000..814d9997
Binary files /dev/null and b/public/fonts/high-empathy.regular.ttf differ
diff --git a/public/fonts/megastina.regular.ttf b/public/fonts/megastina.regular.ttf
new file mode 100644
index 00000000..2f9f8f1b
Binary files /dev/null and b/public/fonts/megastina.regular.ttf differ
diff --git a/public/fonts/royalty-free.regular.ttf b/public/fonts/royalty-free.regular.ttf
new file mode 100644
index 00000000..65b5d8bd
Binary files /dev/null and b/public/fonts/royalty-free.regular.ttf differ
diff --git a/public/fonts/thesignature.regular.ttf b/public/fonts/thesignature.regular.ttf
new file mode 100644
index 00000000..ceb5585a
Binary files /dev/null and b/public/fonts/thesignature.regular.ttf differ