diff --git a/.github/workflows/fork-sync-trigger-develop.yml b/.github/workflows/fork-sync-trigger-develop.yml index 19a4ded5..05b62273 100644 --- a/.github/workflows/fork-sync-trigger-develop.yml +++ b/.github/workflows/fork-sync-trigger-develop.yml @@ -44,6 +44,9 @@ jobs: with: node-version: '20' + - name: Clean npm cache + run: npm cache clean --force + - name: Install Dependencies run: npm i --legacy-peer-deps diff --git a/README.md b/README.md index 4fb2f6c2..d5aa256c 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,4 @@ npm run dev ## Jest Testing ```bash npm run test -``` \ No newline at end of file +``` diff --git a/app/globals.css b/app/globals.css index 04a3e253..85254047 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,22 +26,6 @@ } } -.shiny-text { - background: linear-gradient( - 90deg, - #ffd700 0%, - #fff44f 25%, - #ffeb3b 50%, - #fff44f 75%, - #ffd700 100% - ); - background-size: 200% auto; - background-clip: text; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - animation: shiny 3s linear infinite; -} - @layer base { :root { --background: 0 0% 100%; diff --git a/app/hire/forgot-password/page.tsx b/app/hire/forgot-password/page.tsx index 84e8dc71..b1109abe 100644 --- a/app/hire/forgot-password/page.tsx +++ b/app/hire/forgot-password/page.tsx @@ -49,12 +49,12 @@ const ForgotPasswordForm = ({}) => { setMessage(""); try { - const r = await EmployerUserService.requestPasswordReset(email); + const r = await EmployerUserService.requestPasswordReset(email.toLowerCase()); // @ts-ignore setMessage(r.message); } catch (err: any) { - setError(err.message ?? "Something went wrong. Please try again later."); + setError(err.response?.data?.message ?? err.message ?? "Something went wrong. Please try again later."); } finally { setIsLoading(false); } @@ -75,12 +75,12 @@ const ForgotPasswordForm = ({}) => { Reset password {error && ( -
+

{error}

)} {message && ( -
+

{message}

)} diff --git a/app/hire/layout.tsx b/app/hire/layout.tsx index 5bae964f..1b6fc1a8 100644 --- a/app/hire/layout.tsx +++ b/app/hire/layout.tsx @@ -11,7 +11,7 @@ import Head from "next/head"; import AllowLanding from "./allowLanding"; import { ConversationsContextProvider } from "@/hooks/use-conversation"; import { PocketbaseProvider, usePocketbase } from "@/lib/pocketbase"; -import { ModalProvider } from "@/components/providers/ModalProvider"; +import { ModalProvider } from "@/components/providers/modal-provider/ModalProvider"; import { NotificationListener } from "./notification-listener"; const baseUrl = diff --git a/app/hire/login/page.tsx b/app/hire/login/page.tsx index 4d392434..a36bb8b6 100644 --- a/app/hire/login/page.tsx +++ b/app/hire/login/page.tsx @@ -7,9 +7,7 @@ import { useAuthContext } from "../authctx"; import { cn } from "@/lib/utils"; import { useAppContext } from "@/lib/ctx-app"; -import { - FormInput, -} from "@/components/EditForm"; +import { FormInput } from "@/components/EditForm"; import { Card } from "@/components/ui/card"; import { MailCheck, TriangleAlert, User } from "lucide-react"; @@ -22,7 +20,7 @@ export default function LoginPage() { Loading login...}> - ) + ); } function LoginContent() { @@ -92,18 +90,15 @@ function LoginContent() { } }; - return ( -
@@ -115,10 +110,12 @@ function LoginContent() {
{/* Error Message */} {error && ( -
+
{error}
@@ -126,12 +123,17 @@ function LoginContent() { {/* check email message on successful register */} {status === "success" && !error && ( -
+
- Registration successful. Please check your email for the password. + + Registration successful. Please check your email for the + password. +
)} @@ -154,19 +156,41 @@ function LoginContent() { required />
- Forgot password? -
- Don't have an account? Register here. + Don't have an account?{" "} + + Register here. + - 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/hire/register/page.tsx b/app/hire/register/page.tsx index 9b57a3b6..3ade6096 100644 --- a/app/hire/register/page.tsx +++ b/app/hire/register/page.tsx @@ -6,11 +6,7 @@ import { useAuthContext } from "../authctx"; import { useRouter } from "next/navigation"; import { isValidRequiredURL, toURL } from "@/lib/utils/url-utils"; import { Employer } from "@/lib/db/db.types"; -import { - createEditForm, - FormCheckbox, - FormInput, -} from "@/components/EditForm"; +import { createEditForm, FormCheckbox, FormInput } from "@/components/EditForm"; import { Card } from "@/components/ui/card"; import { ErrorLabel } from "@/components/ui/labels"; import { Button } from "@/components/ui/button"; @@ -39,12 +35,12 @@ export default function RegisterPage() { const { isMobile } = useAppContext(); return ( -
+
@@ -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 = ({
- Already have an account? Log in here. + Already have an account?{" "} + + Log in here. +
- 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 ( + <> +
+ + +
+ + + ); +}; 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. +

+
+ + +
+
+
+ )} +
+ ); +} 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" && ( + + )} + +
+
+ )} +
+ +
+ {!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 && ( + + )} + +
+
+
+ +
+
+
+ +
+
+ {isMobile ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+
+ +
+
+
+ + + Please check if all your inputs are correct + + {!noRecipientStep && ( + + )} + +
+
+ {isMobile ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+
+
+
+
+
+
+
+
+ ); +} 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 ( + + ); + })} +
+ ); +}; diff --git a/app/student/forms/layout.tsx b/app/student/forms/layout.tsx index 715cdfe5..0ee4e366 100644 --- a/app/student/forms/layout.tsx +++ b/app/student/forms/layout.tsx @@ -1,166 +1,9 @@ "use client"; -import { - useState, - useEffect, - createContext, - useContext, - useMemo, - useCallback, - useLayoutEffect, -} from "react"; -import { useRouter, usePathname } from "next/navigation"; import { MyFormsContextProvider } from "./myforms.ctx"; import { FormRendererContextProvider } from "@/components/features/student/forms/form-renderer.ctx"; import { FormFillerContextProvider } from "@/components/features/student/forms/form-filler.ctx"; -import { FormsNavigation } from "@/components/features/student/forms/FormsNavigation"; -import { useMyForms } from "./myforms.ctx"; import { SignContextProvider } from "@/components/providers/sign.ctx"; -import { SonnerToaster } from "@/components/ui/sonner-toast"; -import { useMobile } from "@/hooks/use-mobile"; -import { useHeaderContext, MobileAddonConfig } from "@/lib/ctx-header"; - -interface FormsLayoutContextType { - activeView: "generate" | "history"; - setActiveView: (view: "generate" | "history") => void; - currentFormName: string | null; - setCurrentFormName: (name: string | null) => void; - currentFormLabel: string | null; - setCurrentFormLabel: (label: string | null) => void; -} - -const FormsLayoutContext = createContext( - undefined, -); - -export const useFormsLayout = () => { - const context = useContext(FormsLayoutContext); - if (!context) { - throw new Error("useFormsLayout must be used within FormsLayout"); - } - return context; -}; - -function FormsLayoutContent({ children }: { children: React.ReactNode }) { - const router = useRouter(); - const pathname = usePathname(); - const isMobile = useMobile(); - const { setMobileAddonConfig } = useHeaderContext(); - const myForms = useMyForms(); - const [manualActiveView, setManualActiveView] = useState< - "generate" | "history" | null - >(null); - const [isInitialized, setIsInitialized] = useState(false); - const [currentFormName, setCurrentFormName] = useState(null); - const [currentFormLabel, setCurrentFormLabel] = useState(null); - - const hasFormsToShow = (myForms?.forms?.length ?? 0) > 0; - - // Determine the active view: use manual selection if user clicked nav, otherwise derive from forms - const activeView = useMemo(() => { - if (manualActiveView !== null) { - return manualActiveView; - } - // Default: show history if there are forms, otherwise show generate - return hasFormsToShow ? "history" : "generate"; - }, [manualActiveView, hasFormsToShow]); - - // Check for view query parameter on mount - useEffect(() => { - if (typeof window !== "undefined") { - const params = new URLSearchParams(window.location.search); - const viewParam = params.get("view"); - if (viewParam === "history" || viewParam === "generate") { - setManualActiveView(viewParam); - // Clean up URL after reading param - window.history.replaceState({}, "", pathname); - } - // Mark as initialized regardless of whether we found a param - setIsInitialized(true); - } - }, [pathname]); - - const handleViewChange = useCallback( - (view: "generate" | "history") => { - setManualActiveView(view); - // If we're on a detail page, navigate back to /forms - if (pathname !== "/forms") { - router.push("/forms"); - } - }, - [pathname, router], - ); - - // Compute mobile addon config directly based on current state - const mobileAddonConfig: MobileAddonConfig = useMemo(() => { - if (!isMobile || !isInitialized) { - return { show: false }; - } - return { - show: true, - activeView, - onViewChange: handleViewChange, - currentFormName, - currentFormLabel, - }; - }, [ - isMobile, - isInitialized, - activeView, - handleViewChange, - currentFormName, - currentFormLabel, - ]); - - // Sync config to context after render - useLayoutEffect(() => { - setMobileAddonConfig(mobileAddonConfig); - }, [ - mobileAddonConfig.show, - mobileAddonConfig.activeView, - mobileAddonConfig.currentFormName, - mobileAddonConfig.currentFormLabel, - ]); - - // Clear addon config when layout unmounts - useLayoutEffect(() => { - return () => { - setMobileAddonConfig({ show: false }); - }; - }, [setMobileAddonConfig]); - - return ( - -
-
- -
- -
- {!isInitialized ?
: children} -
-
- - ); -} const FormsLayout = ({ children }: { children: React.ReactNode }) => { return ( @@ -171,10 +14,7 @@ const FormsLayout = ({ children }: { children: React.ReactNode }) => { - - {children} - - + {children} diff --git a/app/student/forms/myforms.ctx.tsx b/app/student/forms/myforms.ctx.tsx index 77c3d48f..0aea7469 100644 --- a/app/student/forms/myforms.ctx.tsx +++ b/app/student/forms/myforms.ctx.tsx @@ -1,7 +1,7 @@ /** * @ Author: BetterInternship * @ Create Time: 2025-12-18 15:17:08 - * @ Modified time: 2026-01-06 19:30:02 + * @ Modified time: 2026-03-04 17:03:50 * @ Description: * * These are the forms a user has generated or initiated. @@ -25,7 +25,7 @@ interface IMyForms { timestamp: string; rejection_reason?: string; signing_parties?: IFormSigningParty[]; - status?: string; + status?: string | null; }[]; loading: boolean; error?: string; @@ -55,7 +55,7 @@ export const MyFormsContextProvider = ({ } = useQuery({ queryKey: ["my-forms"], queryFn: () => FormService.getMyGeneratedForms(), - staleTime: 0, + staleTime: Infinity, gcTime: 60 * 60 * 1000, }); diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx index 3f89ae69..2e1b649c 100644 --- a/app/student/forms/page.tsx +++ b/app/student/forms/page.tsx @@ -3,17 +3,20 @@ import { useProfileData } from "@/lib/api/student.data.api"; import { useRouter } from "next/navigation"; import { FormService } from "@/lib/api/services"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMyForms } from "./myforms.ctx"; -import { FormGenerateView } from "../../../components/forms/FormGenerateView"; -import { FormHistoryView } from "../../../components/forms/FormHistoryView"; -import { useFormsLayout } from "./layout"; import { FORM_TEMPLATES_STALE_TIME, FORM_TEMPLATES_GC_TIME, } from "@/lib/consts/cache"; import { useEffect } from "react"; import { useAuthContext } from "@/lib/ctx-auth"; +import FormDashboard from "./components/FormDashboard"; +import { + useFormFilloutProcessHandled, + useFormFilloutProcessPending, + useFormFilloutProcessReader, +} from "@/hooks/forms/filloutFormProcess"; /** * The forms page component - shows either history or generate based on form count @@ -24,7 +27,7 @@ export default function FormsPage() { const profile = useProfileData(); const router = useRouter(); const myForms = useMyForms(); - const { activeView } = useFormsLayout(); + const queryClient = useQueryClient(); const { redirectIfNotLoggedIn, isAuthenticated } = useAuthContext(); // Auth redirect at body level (runs first) @@ -62,14 +65,29 @@ export default function FormsPage() { // enabled: !!updateInfo, // Only fetch after we have update info }); + // ? I think I can abstract this somehow in the future + // ? How it works right now: + // ? The form history renders a combination of the pulled forms (from the db) AND the pending forms (from the client process manager) + // ? BUT when a pending form turns into a handled form, the pulled forms doesn't update right away + // ? SO handled forms are also temporarily rendered WHILE they're not part of the pulled forms yet + // ? All the logic below really does is make sure that once the handled forms are in the pulled forms, they're not rendered anymore + // ? There has to be a better way to do this repeatably + const formFilloutProcess = useFormFilloutProcessReader(); + const pendingForms = useFormFilloutProcessPending(); + const handledForms = useFormFilloutProcessHandled(); + + // Refetch forms when no more pending left + // Yeppers kinda janky I know + useEffect(() => { + if (!formFilloutProcess.getAllPending().length) + void queryClient.invalidateQueries({ queryKey: ["my-forms"] }); + }, [formFilloutProcess.getAllPending()]); + return ( - <> - {/* Show the active view */} - {activeView === "history" ? ( - - ) : ( - - )} - + !!ft) ?? []} + isLoading={isLoading} + /> ); } diff --git a/app/student/layout.tsx b/app/student/layout.tsx index f054d63d..5649a9e9 100644 --- a/app/student/layout.tsx +++ b/app/student/layout.tsx @@ -10,8 +10,10 @@ import TanstackProvider from "../tanstack-provider"; import AllowLanding from "./allowLanding"; import { ConversationsContextProvider } from "@/hooks/use-conversation"; import { PocketbaseProvider } from "@/lib/pocketbase"; -import { ModalProvider } from "@/components/providers/ModalProvider"; +import { ModalProvider } from "@/components/providers/modal-provider/ModalProvider"; import MobileNavWrapper from "@/components/shared/mobile-nav-wrapper"; +import { SonnerToaster } from "@/components/ui/sonner-toast"; +import { ClientProcessesProvider } from "@betterinternship/components"; const baseUrl = process.env.NEXT_PUBLIC_CLIENT_URL || "https://betterinternship.com"; @@ -93,16 +95,19 @@ const HTMLContent = ({ - - -
-
- {children} + + + +
+
+ {children} +
+
- -
- - + + + + diff --git a/app/student/profile/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 ? ( - - ) : ( - - - - - - You’ll complete the form and sign it by hand after printing. - - - )} - - {!noEsign && ( - - - - - - 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 -
- )} -
- -
- -
-
- )} - - {/* Mobile: Form Stage - Show Form Only */} - {mobileStage === "form" && ( -
-
- {form.loading ? ( -
- - - Loading form… - -
- ) : ( - - )} -
- -
- - -
-
- )} - - {/* 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 -
- )} -
- -
- -
- -
-
-
- )} -
- - {/* ============ 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 ( -
- -
-
- {formLabel} -
-
- - -
-
- ); -} 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} -
-
- - +
+ + +
+ {Math.round(scale * 100)}% -
{/* 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 -
- -
-
- -

- Need help? Contact us via{" "} - - Facebook - {" "} - or{" "} - - email - -

-
- -
- -
-
-
-
- - - -
- {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" && ( <> )} - {documentId ? ( + {status === "done" ? ( ) : ( @@ -175,7 +211,7 @@ export const FormLog = ({ {/* Mobile chevron/download */}
- {documentId ? ( + {status === "done" ? ( ) : ( @@ -207,7 +247,7 @@ export const FormLog = ({
{/* Mobile action buttons */} - {!rejectionReason && !documentId && ( + {!rejectionReason && status !== "done" && (
- - {isProcessStoryOpen && ( -
- -
- )} - -
- {!busy && !submitted && ( - - )} - -
-
- ); -}; 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 && ( - - )} -
- )} - - {/* Content area */} -
-
- {node} -
-
- - {/* 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 && ( + + )} +
+ )} + +
+
+ {children} +
+
+ +
+ + ); +}; + +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 logo 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 ? ( -
+