From e9c027d94fe00db229b62486e716a27aa398f3f0 Mon Sep 17 00:00:00 2001 From: Mo David Date: Sat, 6 Jun 2026 16:34:14 +0800 Subject: [PATCH 01/19] fix: reset password page wrong callback + params usage --- app/hire/reset-password/[hash]/page.tsx | 11 ++++------- app/student/forms/page.tsx | 1 + lib/db/db.types.ts | 2 +- lib/db/use-bi-moa-backend.ts | 2 +- lib/db/use-refs-backend.ts | 2 +- package.json | 3 +-- 6 files changed, 9 insertions(+), 12 deletions(-) diff --git a/app/hire/reset-password/[hash]/page.tsx b/app/hire/reset-password/[hash]/page.tsx index e54e2db5..69350d76 100644 --- a/app/hire/reset-password/[hash]/page.tsx +++ b/app/hire/reset-password/[hash]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { Card } from "@/components/ui/card"; import { FormInput } from "@/components/EditForm"; import { Button } from "@/components/ui/button"; @@ -17,11 +17,8 @@ import { useBlurTransition } from "@/components/animata/blur"; * Display the layout for the change password page. */ -export default function ResetPasswordPage({ - params, -}: { - params: { hash: string }; -}) { +export default function ResetPasswordPage() { + const params = useParams(); const { isMobile } = useAppContext(); const { hash } = params; @@ -33,7 +30,7 @@ export default function ResetPasswordPage({ )} >
- +
); diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx index 41b1e673..40733d5a 100644 --- a/app/student/forms/page.tsx +++ b/app/student/forms/page.tsx @@ -41,6 +41,7 @@ export default function FormsPage() { ); useEffect(() => { + console.log("PORFIE", profile.data); if (profile.isPending) return; setHasFormsAccess((currentAccess) => { diff --git a/lib/db/db.types.ts b/lib/db/db.types.ts index 556f0030..ee16b14c 100644 --- a/lib/db/db.types.ts +++ b/lib/db/db.types.ts @@ -20,7 +20,7 @@ import { CareerApplications, CareerSavedJobs, CareerResumes, -} from "@betterinternship/schema.base"; +} from "@betterinternship/schema"; import { Selectable } from "kysely"; export type Database = DB; diff --git a/lib/db/use-bi-moa-backend.ts b/lib/db/use-bi-moa-backend.ts index ad224c32..4be06043 100644 --- a/lib/db/use-bi-moa-backend.ts +++ b/lib/db/use-bi-moa-backend.ts @@ -10,7 +10,7 @@ import "server-only"; import { Moa } from "./db.types"; import { Kysely, PostgresDialect } from "kysely"; -import { DB } from "@betterinternship/schema.base"; +import { DB } from "@betterinternship/schema"; import { Pool } from "pg"; const DATABASE_URL = process.env.DATABASE_URL; diff --git a/lib/db/use-refs-backend.ts b/lib/db/use-refs-backend.ts index eb967486..61108e53 100644 --- a/lib/db/use-refs-backend.ts +++ b/lib/db/use-refs-backend.ts @@ -22,7 +22,7 @@ import { RefsData, IRefsContext, } from "./db.types"; -import { DB } from "@betterinternship/schema.base"; +import { DB } from "@betterinternship/schema"; import { Kysely, PostgresDialect } from "kysely"; import { Pool } from "pg"; diff --git a/package.json b/package.json index 7e1c2b3e..1e006019 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,7 @@ "dependencies": { "@betterinternship/components": "1.5.28", "@betterinternship/core": "^2.12.2", - "@betterinternship/schema.base": "3.2.0", - "@betterinternship/schema.moa": "^1.5.15", + "@betterinternship/schema": "^0.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", From 0daa980416757f8e587019bd75c98afe6ea3c16b Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 22:25:50 +0800 Subject: [PATCH 02/19] fix: add csp --- next.config.mjs | 57 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 0a0f5426..fd147314 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,43 @@ -/** @type {import('next').NextConfig} */ +const apiUrls = [ + process.env.NEXT_PUBLIC_API_URL, + process.env.NEXT_PUBLIC_MOA_API_URL, + process.env.NEXT_PUBLIC_API_SERVER_URL, + process.env.NEXT_PUBLIC_SUPABASE_URL, +].filter(Boolean); + +const connectOrigins = apiUrls + .map((url) => { + try { + const parsed = new URL(url); + const origin = parsed.origin; + if (origin.startsWith("https://")) { + return `${origin} ${origin.replace("https://", "wss://")}`; + } + if (origin.startsWith("http://")) { + return `${origin} ${origin.replace("http://", "ws://")}`; + } + return origin; + } catch (e) { + return ""; + } + }) + .filter(Boolean) + .join(" "); + +const cspHeader = ` + default-src 'self'; + script-src 'self' 'unsafe-eval' 'unsafe-inline'; + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; + img-src 'self' blob: data:; + font-src 'self' https://fonts.gstatic.com; + connect-src 'self' http://localhost:* ${connectOrigins}; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; + upgrade-insecure-requests; +`.replace(/\n/g, ""); + const nextConfig = { eslint: { ignoreDuringBuilds: true, @@ -9,6 +48,22 @@ const nextConfig = { images: { unoptimized: true, }, + + // content security policy headers + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy", + value: cspHeader, + }, + ], + }, + ]; + }, + async rewrites() { const routes = [ { From f55bc123e9b750c636929f67df8269d1ee9446a6 Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 22:46:13 +0800 Subject: [PATCH 03/19] fix: allow pdfs through csp --- next.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/next.config.mjs b/next.config.mjs index fd147314..e0bb5285 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -27,6 +27,7 @@ const connectOrigins = apiUrls const cspHeader = ` default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; + frame-src 'self' http://localhost:* ${connectOrigins}; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' blob: data:; font-src 'self' https://fonts.gstatic.com; From 304615f8861accc5497a283b8ee777fd69bcc69c Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 22:59:12 +0800 Subject: [PATCH 04/19] chore: update jspdf --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e006019..e849d4cd 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "gsap": "^3.13.0", "immer": "^10.1.1", "input-otp": "^1.4.2", - "jspdf": "^3.0.1", + "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.2", "jszip": "^3.10.1", "knuth-shuffle-seeded": "^1.0.6", From 6143dd726e4209942757babe8b920d79ca7ce10f Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 23:09:40 +0800 Subject: [PATCH 05/19] feat: https strict transport security --- next.config.mjs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/next.config.mjs b/next.config.mjs index e0bb5285..de9311d3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -60,6 +60,10 @@ const nextConfig = { key: "Content-Security-Policy", value: cspHeader, }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, ], }, ]; From a00cfd4bac09ca811483c79e26ab7af06d66844a Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 23:29:34 +0800 Subject: [PATCH 06/19] feat: add security headers --- next.config.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/next.config.mjs b/next.config.mjs index de9311d3..b41e010e 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -64,6 +64,22 @@ const nextConfig = { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload", }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "self", + }, ], }, ]; From 6a5639154a728243898d085a98d2b6594fc84aa8 Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 23:30:01 +0800 Subject: [PATCH 07/19] fix: incorrect permissions-policy header --- next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index b41e010e..f486c985 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -78,7 +78,7 @@ const nextConfig = { }, { key: "Permissions-Policy", - value: "self", + value: "camera=(), microphone=(), geolocation=()", }, ], }, From 0faf9a665452995321b70e425bbcc128fe7dd0c3 Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 23:30:26 +0800 Subject: [PATCH 08/19] fix: clear query client on logout for hire and student --- app/hire/authctx.tsx | 7 +------ lib/ctx-auth.tsx | 10 +--------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/app/hire/authctx.tsx b/app/hire/authctx.tsx index 3c17054f..714b1c25 100644 --- a/app/hire/authctx.tsx +++ b/app/hire/authctx.tsx @@ -138,12 +138,7 @@ export const AuthContextProvider = ({ const logout = async () => { await EmployerAuthService.logout(); - - await queryClient.invalidateQueries({ queryKey: ["my-employer-profile"] }); - await queryClient.invalidateQueries({ - queryKey: ["my-employer-conversations"], - }); - + queryClient.clear(); router.push("/login"); setUser(null); setGod(false); diff --git a/lib/ctx-auth.tsx b/lib/ctx-auth.tsx index c7939099..8f68747d 100644 --- a/lib/ctx-auth.tsx +++ b/lib/ctx-auth.tsx @@ -112,15 +112,7 @@ export const AuthContextProvider = ({ const logout = async () => { await AuthService.logout(); - await queryClient.invalidateQueries({ queryKey: ["jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); - await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); - await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); - await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); - await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); - await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); - await queryClient.invalidateQueries({ queryKey: ["my-resumes"] }); + queryClient.clear(); setIsAuthenticated(false); }; From 08f308214f8a27176b30872d2c43b1cea4c1ba9e Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sat, 6 Jun 2026 23:36:31 +0800 Subject: [PATCH 09/19] chore: update postcss --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e849d4cd..ab9d5c2f 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,7 @@ "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", "jest": "^30.2.0", - "postcss": "^8", + "postcss": "^8.5.15", "prettier": "^3.4.2", "tailwindcss": "^3.4.17", "ts-jest": "^29.4.5", From 3c4bea397b3159d58fce3a8b88fe16c3f2ec4a1e Mon Sep 17 00:00:00 2001 From: jayylmao <73204320+jayylmao@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:13:22 +0800 Subject: [PATCH 10/19] fix: remove unnecessary unsafe csp exceptions --- next.config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index f486c985..81cab549 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -26,9 +26,9 @@ const connectOrigins = apiUrls const cspHeader = ` default-src 'self'; - script-src 'self' 'unsafe-eval' 'unsafe-inline'; + script-src 'self' 'unsafe-inline'; frame-src 'self' http://localhost:* ${connectOrigins}; - style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; + style-src 'self' https://fonts.googleapis.com; img-src 'self' blob: data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:* ${connectOrigins}; From a1e5bcdadd5109743e71af7746b826fc3790903a Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sun, 7 Jun 2026 23:40:50 +0800 Subject: [PATCH 11/19] fix: blank page on deploy due to strict csp --- next.config.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 81cab549..97206cf8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -24,12 +24,23 @@ const connectOrigins = apiUrls .filter(Boolean) .join(" "); +const imageOrigins = apiUrls + .map((url) => { + try { + return new URL(url).origin; + } catch (e) { + return ""; + } + }) + .filter(Boolean) + .join(" "); + const cspHeader = ` default-src 'self'; - script-src 'self' 'unsafe-inline'; + script-src 'self' 'unsafe-eval' 'unsafe-inline'; frame-src 'self' http://localhost:* ${connectOrigins}; - style-src 'self' https://fonts.googleapis.com; - img-src 'self' blob: data:; + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; + img-src 'self' blob: data: http://localhost:* ${imageOrigins}; font-src 'self' https://fonts.gstatic.com; connect-src 'self' http://localhost:* ${connectOrigins}; object-src 'none'; From 90be3e9d3ae78dacb4adce42b0d7c94ef62f9008 Mon Sep 17 00:00:00 2001 From: jayylmao Date: Sun, 7 Jun 2026 23:50:49 +0800 Subject: [PATCH 12/19] fix: add cloudflare to script csp exceptions --- next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 97206cf8..24e8632d 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -37,7 +37,7 @@ const imageOrigins = apiUrls const cspHeader = ` default-src 'self'; - script-src 'self' 'unsafe-eval' 'unsafe-inline'; + script-src 'self' 'unsafe-eval' 'unsafe-inline' https://cdnjs.cloudflare.com; frame-src 'self' http://localhost:* ${connectOrigins}; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' blob: data: http://localhost:* ${imageOrigins}; From 55798d05a617745e1f2e2697018bc23ab6089b45 Mon Sep 17 00:00:00 2001 From: jayylmao Date: Mon, 8 Jun 2026 00:25:50 +0800 Subject: [PATCH 13/19] fix: add google storage to connect csp exceptions --- next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index 24e8632d..fe86bf03 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -42,7 +42,7 @@ const cspHeader = ` style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' blob: data: http://localhost:* ${imageOrigins}; font-src 'self' https://fonts.gstatic.com; - connect-src 'self' http://localhost:* ${connectOrigins}; + connect-src 'self' http://localhost:* https://storage.googleapis.com ${connectOrigins}; object-src 'none'; base-uri 'self'; form-action 'self'; From 16ef0d4434e3c4c67bc07cc29de7700418ee8548 Mon Sep 17 00:00:00 2001 From: Jana Marie Bantolino Date: Mon, 8 Jun 2026 20:04:59 +0800 Subject: [PATCH 14/19] chore: use PDF viewer from package now --- .../forms/components/FormSigningLayout.tsx | 47 +- .../student/forms/form-previewer.ctx.tsx | 12 - .../features/student/forms/previewer.tsx | 890 ------------------ components/modals/modal-registry.tsx | 5 +- lib/form-previewer-model.ts | 303 ------ lib/form-previewer-rendering.ts | 459 --------- package.json | 3 +- 7 files changed, 46 insertions(+), 1673 deletions(-) delete mode 100644 components/features/student/forms/form-previewer.ctx.tsx delete mode 100644 components/features/student/forms/previewer.tsx delete mode 100644 lib/form-previewer-model.ts delete mode 100644 lib/form-previewer-rendering.ts diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx index 0feb000a..80a54e41 100644 --- a/app/student/forms/components/FormSigningLayout.tsx +++ b/app/student/forms/components/FormSigningLayout.tsx @@ -1,7 +1,7 @@ "use client"; import { ArrowLeft, LucideClipboardCheck, MailWarningIcon } from "lucide-react"; -import { FormPreviewPdfDisplay } from "@/components/features/student/forms/previewer"; +import { FormFillPdfViewer } from "@betterinternship/core/pdf-viewer"; import { FormFillerRenderer } from "@/components/features/student/forms/FormFillerRenderer"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -29,6 +29,7 @@ import { getRecipientEmailErrors } from "./recipient-email-validation"; import { useSignContext } from "@/components/providers/sign.ctx"; import { withSubmittedSignatureImages } from "@/lib/signature-image-submit"; import { useProfileData } from "@/lib/api/student.data.api"; +import { useDbRefs } from "@/lib/db/use-refs"; interface FlowTestSigningLayoutProps { formLabel?: string; @@ -116,6 +117,7 @@ export function FormSigningLayout({ }: FlowTestSigningLayoutProps) { const form = useFormRendererContext(); const profile = useProfileData(); + const refs = useDbRefs(); const modalRegistry = useModalRegistry(); const formFiller = useFormFiller(); const autofillValues = useMyAutofill(); @@ -326,10 +328,40 @@ export function FormSigningLayout({ () => withDerivedFormValues(form.formMetadata, previewValues), [form.formMetadata, previewValues], ); + const previewValuesResolved = useMemo(() => { + const resolved = { ...previewValuesWithDerived }; + for (const [key, value] of Object.entries(previewValuesWithDerived)) { + if (!value) continue; + const trimmed = String(value).trim(); + const keyLower = key.toLowerCase(); + if (keyLower.includes("university") && refs.to_university_name) { + resolved[key] = refs.to_university_name(trimmed, trimmed) ?? trimmed; + } else if (keyLower.includes("college") && refs.to_college_name) { + resolved[key] = refs.to_college_name(trimmed, trimmed) ?? trimmed; + } else if (keyLower.includes("department") && refs.to_department_name) { + resolved[key] = refs.to_department_name(trimmed, trimmed) ?? trimmed; + } + } + return resolved; + }, [previewValuesWithDerived, refs]); const wetSignatureHiddenFieldNames = useMemo( () => (noEsign ? getSignatureDerivedFieldNames(form.formMetadata) : []), [form.formMetadata, noEsign], ); + const previewBlocksForViewer = useMemo(() => { + const hiddenSet = new Set(wetSignatureHiddenFieldNames.map(normalizeFieldName)); + const raw = noEsign + ? previewKeyedFields.filter( + (field) => + field.type !== "signature" && + !hiddenSet.has(normalizeFieldName(field.field)), + ) + : previewKeyedFields; + return raw.map((field) => ({ + ...field, + id: field.id || field._id || `${field.field}:${field.page}:${field.x}:${field.y}`, + })); + }, [previewKeyedFields, noEsign, wetSignatureHiddenFieldNames]); const computeRequiredFieldsComplete = useCallback( (nextValues: FormValues) => @@ -840,21 +872,24 @@ export function FormSigningLayout({ )} {documentUrl ? ( <> - | null} + showOwnership={!noEsign} + fieldVisibility="all" + currentSigningPartyId={!noEsign ? "initiator" : undefined} /> ) : ( diff --git a/components/features/student/forms/form-previewer.ctx.tsx b/components/features/student/forms/form-previewer.ctx.tsx deleted file mode 100644 index fad661b9..00000000 --- a/components/features/student/forms/form-previewer.ctx.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { createContext, useContext } from "react"; - -interface IFormPreviewerContext { - fields: []; - previews: []; -} - -// Context defs -const FormPreviewerContext = createContext( - {} as IFormPreviewerContext, -); -export const useFormPreviewerContext = () => useContext(FormPreviewerContext); diff --git a/components/features/student/forms/previewer.tsx b/components/features/student/forms/previewer.tsx deleted file mode 100644 index 8fed9823..00000000 --- a/components/features/student/forms/previewer.tsx +++ /dev/null @@ -1,890 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - GlobalWorkerOptions, - getDocument, - version as pdfjsVersion, -} from "pdfjs-dist"; -import { - getSignatureImageFieldKey, - parseSignatureImageValue, - type IFormSigningParty, - type SignatureImageValue, -} 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 { Loader } from "@/components/ui/loader"; -import { ZoomIn, ZoomOut } from "lucide-react"; -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?: PreviewFieldInput[]; - values: Record; - headerLeft?: React.ReactNode; - scale?: number; - onFieldClick?: (fieldName: string) => void; - selectedFieldId?: string; - selectionTick?: number; - autoScrollToSelectedField?: boolean; - fieldErrors?: Record; - signingParties?: IFormSigningParty[]; - wetSignatureMode?: boolean; - hiddenFieldNames?: string[]; -} - -const clamp = (value: number, min: number, max: number) => - Math.min(max, Math.max(min, value)); - -const SIGNATURE_IMAGE_OVERFLOW_SCALE = 1.8; - -const getSignatureImageSrc = (signatureImage: SignatureImageValue | null) => { - if (!signatureImage) return ""; - if (signatureImage.image.storage === "bucket") { - return signatureImage.image.signedUrl || signatureImage.image.publicUrl || ""; - } - return signatureImage.image.dataUrl; -}; - -const getSignatureImageValueForField = ( - values: Record, - fieldName: string, -) => { - const normalizedFieldName = normalizePreviewFieldKey(fieldName); - const candidates = [ - getSignatureImageFieldKey(fieldName), - getSignatureImageFieldKey(`${normalizedFieldName}:default`), - getSignatureImageFieldKey(normalizedFieldName), - ]; - - for (const key of candidates) { - const value = values[key]; - if (value) return value; - } - - const matchingEntry = Object.entries(values).find(([key, value]) => { - if (!key.startsWith("__signatureImage:") || !value) return false; - return normalizePreviewFieldKey(key.slice("__signatureImage:".length)) === normalizedFieldName; - }); - - return matchingEntry?.[1]; -}; - -/** - * PDF display component that shows form fields as boxes overlaid on the PDF - * Similar to PdfViewer but in read-only preview mode - * Shows field boxes with current filled values - */ -export const FormPreviewPdfDisplay = ({ - documentUrl, - blocks, - values, - headerLeft, - scale: initialScale, - onFieldClick, - selectedFieldId, - selectionTick = 0, - autoScrollToSelectedField = true, - fieldErrors = {}, - signingParties = [], - wetSignatureMode = false, - hiddenFieldNames = [], -}: 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 ?? defaultScale); - const [visiblePage, setVisiblePage] = useState(1); - const [isLoadingDoc, setIsLoadingDoc] = useState(false); - const [error, setError] = useState(null); - const [contextLabelFieldId, setContextLabelFieldId] = useState( - null, - ); - const [isContextLabelVisible, setIsContextLabelVisible] = useState(false); - const CONTEXT_LABEL_VISIBLE_MS = 900; - const CONTEXT_LABEL_TOTAL_MS = 1150; - const previousValuesRef = useRef>({}); - const hasInitializedValueDiffRef = useRef(false); - const contextLabelFadeTimeoutRef = useRef | null>(null); - const contextLabelClearTimeoutRef = useRef | null>(null); - - const pageRefs = useRef>(new Map()); - const fieldRefs = useRef>(new Map()); - const scrollContainerRef = useRef(null); - const normalizedFields = useMemo( - () => toPreviewFields(blocks ?? []), - [blocks], - ); - const hiddenFieldNameSet = useMemo( - () => new Set(hiddenFieldNames.map(normalizePreviewFieldKey)), - [hiddenFieldNames], - ); - const displayFields = useMemo( - () => - wetSignatureMode - ? normalizedFields.filter( - (field) => - field.type !== "signature" && - !hiddenFieldNameSet.has(normalizePreviewFieldKey(field.field)), - ) - : normalizedFields, - [hiddenFieldNameSet, normalizedFields, wetSignatureMode], - ); - const fieldsByPage = useMemo( - () => groupFieldsByPage(displayFields), - [displayFields], - ); - 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); - }, - [], - ); - - const triggerContextLabel = useCallback((fieldId: string) => { - setContextLabelFieldId(fieldId); - setIsContextLabelVisible(true); - - if (contextLabelFadeTimeoutRef.current) - clearTimeout(contextLabelFadeTimeoutRef.current); - if (contextLabelClearTimeoutRef.current) - clearTimeout(contextLabelClearTimeoutRef.current); - - contextLabelFadeTimeoutRef.current = setTimeout( - () => setIsContextLabelVisible(false), - CONTEXT_LABEL_VISIBLE_MS, - ); - contextLabelClearTimeoutRef.current = setTimeout( - () => setContextLabelFieldId(null), - CONTEXT_LABEL_TOTAL_MS, - ); - }, []); - - // Re-apply default zoom when a new document is opened. - useEffect(() => { - setScale(initialScale ?? defaultScale); - }, [documentUrl, initialScale, defaultScale]); - - // Show contextual label when a mapped field value changes. - useEffect(() => { - if (!hasInitializedValueDiffRef.current) { - hasInitializedValueDiffRef.current = true; - previousValuesRef.current = values; - return; - } - - const previousValues = previousValuesRef.current; - const changedKeys = Object.keys(values).filter( - (key) => previousValues[key] !== values[key], - ); - previousValuesRef.current = values; - - if (!changedKeys.length || isMobile) return; - - const changedFieldId = normalizePreviewFieldKey(changedKeys[0]); - triggerContextLabel(changedFieldId); - }, [values, isMobile, triggerContextLabel]); - - // Jump to field's page and trigger animation when selected from form - useEffect(() => { - if (!selectedFieldId) return; - - 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" }); - } - } - } - - if (!isMobile && autoScrollToSelectedField) { - triggerContextLabel(selectedFieldId); - } - }, [ - selectedFieldId, - selectionTick, - normalizedFields, - autoScrollToSelectedField, - isMobile, - triggerContextLabel, - ]); - - // Initialize PDF.js worker - useEffect(() => { - if (typeof window === "undefined") return; - const workerFile = pdfjsVersion.startsWith("4") - ? "pdf.worker.min.mjs" - : "pdf.worker.min.js"; - GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsVersion}/${workerFile}`; - }, []); - - useEffect(() => { - ensurePreviewFontsLoaded(); - }, []); - - useEffect(() => { - return () => { - if (contextLabelFadeTimeoutRef.current) - clearTimeout(contextLabelFadeTimeoutRef.current); - if (contextLabelClearTimeoutRef.current) - clearTimeout(contextLabelClearTimeoutRef.current); - }; - }, []); - - // Load PDF document - useEffect(() => { - if (!documentUrl) return; - - setIsLoadingDoc(true); - let cancelled = false; - const loadingTask = getDocument({ url: documentUrl }); - - loadingTask.promise - .then((doc) => { - if (!cancelled) { - setPdfDoc(doc); - setPageCount(doc.numPages); - setError(null); - } - }) - .catch((err) => { - if (!cancelled) { - 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); - } - }) - .finally(() => { - if (!cancelled) setIsLoadingDoc(false); - }); - - return () => { - cancelled = true; - void loadingTask.destroy(); - }; - }, [documentUrl]); - - const registerPageRef = useCallback( - (page: number, node: HTMLDivElement | null) => { - pageRefs.current.set(page, node); - }, - [], - ); - - const handleZoom = (direction: "in" | "out") => { - const delta = direction === "in" ? 0.1 : -0.1; - setScale((prev) => clamp(parseFloat((prev + delta).toFixed(2)), 0.5, 3)); - }; - - const pagesArray = useMemo( - () => Array.from({ length: pageCount }, (_, idx) => idx + 1), - [pageCount], - ); - - if (error) { - return ( -
-
-

Failed to load PDF

-

{error}

-
-
- ); - } - - if (isLoadingDoc || !pdfDoc) { - return ( -
- -
- ); - } - - return ( -
- {/* Top Controls */} -
-
- {headerLeft ?
{headerLeft}
: null} -
- - {visiblePage}/{pageCount} - -
- - -
- - {Math.round(scale * 100)}% - -
-
-
- - {/* Pages container */} -
-
- {pagesArray.map((pageNumber) => ( - setVisiblePage(pageNumber)} - registerPageRef={registerPageRef} - fields={fieldsByPage.get(pageNumber) || []} - values={values} - onFieldClick={onFieldClick} - selectedFieldId={selectedFieldId} - contextLabelFieldId={contextLabelFieldId} - isContextLabelVisible={isContextLabelVisible} - registerFieldRef={registerFieldRef} - fieldErrors={fieldErrors} - resolveDisplayValue={resolveDisplayValue} - signingPartyLabelById={signingPartyLabelById} - wetSignatureMode={wetSignatureMode} - /> - ))} -
-
-
- ); -}; - -interface PdfPageWithFieldsProps { - pdf: PDFDocumentProxy; - pageNumber: number; - scale: number; - isVisible: boolean; - onVisible: (page: number) => void; - registerPageRef: (page: number, node: HTMLDivElement | null) => void; - fields: PreviewField[]; - values: Record; - onFieldClick?: (fieldName: string) => void; - selectedFieldId?: string; - contextLabelFieldId?: string | null; - isContextLabelVisible?: boolean; - registerFieldRef: (fieldName: string, node: HTMLDivElement | null) => void; - fieldErrors: Record; - resolveDisplayValue: (field: PreviewField, rawValue: unknown) => string; - signingPartyLabelById: Map; - wetSignatureMode: boolean; -} - -const PdfPageWithFields = ({ - pdf, - pageNumber, - scale, - isVisible: _isVisible, - onVisible, - registerPageRef, - fields, - values, - onFieldClick, - selectedFieldId, - contextLabelFieldId, - isContextLabelVisible, - registerFieldRef, - fieldErrors, - resolveDisplayValue, - signingPartyLabelById, - wetSignatureMode, -}: 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 - - useEffect( - () => registerPageRef(pageNumber, containerRef.current), - [pageNumber, registerPageRef], - ); - - // Force re-render of field positions when scale changes - useEffect(() => { - 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; - if (!element) return; - - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) { - onVisible(pageNumber); - } - }, - { threshold: 0.6 }, - ); - - observer.observe(element); - return () => observer.disconnect(); - }, [onVisible, pageNumber]); - - // Render PDF page - useEffect(() => { - let renderTask: RenderTask | null = null; - let cancelled = false; - setRendering(true); - - pdf - .getPage(pageNumber) - .then((page: PDFPageProxy) => { - // Account for device pixel ratio for crisp rendering on high-DPI displays - const dpr = - typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; - const viewport = page.getViewport({ scale: scale * dpr }); - viewportRef.current = viewport; - - const canvas = canvasRef.current; - if (!canvas) return; - - canvas.width = viewport.width; - canvas.height = viewport.height; - - // Set CSS pixel size to match logical size (divided by dpr) - canvas.style.width = `${viewport.width / dpr}px`; - canvas.style.height = `${viewport.height / dpr}px`; - - const canvasContext = canvas.getContext("2d"); - if (!canvasContext) return; - - renderTask = page.render({ - canvasContext, - viewport, - }); - - return renderTask.promise; - }) - .catch((err) => { - if (!cancelled) - console.error(`Failed to render page ${pageNumber}:`, err); - }) - .finally(() => { - if (!cancelled) setRendering(false); - }); - - return () => { - cancelled = true; - renderTask?.cancel(); - }; - }, [pdf, pageNumber, scale]); - - // Convert PDF coordinates to display coordinates, accounting for zoom-aware rendering - const pdfToDisplay = ( - pdfX: number, - pdfY: number, - ): { displayX: number; displayY: number } | null => { - const canvas = canvasRef.current; - const viewport = viewportRef.current; - if (!canvas || !viewport) return null; - - // Metadata coordinates already use top-left origin (y=0 at top) - // Scale them directly to display coordinates - const displayX = pdfX * scale; - const displayY = pdfY * scale; - - return { - displayX, - displayY, - }; - }; - - return ( -
- {rendering && ( -
- -
- )} - - {/* Canvas - PDF page */} - - - {/* Field boxes overlay */} -
{ - 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; - } - - const displayPos = pdfToDisplay(x, y); - if (!displayPos) { - return null; - } - - const widthPixels = w * scale; - const heightPixels = h * scale; - - const owner = String(field.signing_party_id ?? "").toLowerCase(); - const hasAssignedOwner = owner.length > 0; - const isOwnedByInitiator = - !hasAssignedOwner || owner === "initiator" || owner === "student"; - const shouldDisplayValue = wetSignatureMode || isOwnedByInitiator; - const normalizedFieldName = normalizePreviewFieldKey(fieldName); - const fieldType: PreviewField["type"] = field.type ?? "text"; - const signatureImage = - fieldType === "signature" && shouldDisplayValue - ? parseSignatureImageValue(getSignatureImageValueForField(values, fieldName)) - : null; - const signatureImageSrc = getSignatureImageSrc(signatureImage); - const rawValue = shouldDisplayValue - ? (values[fieldName] ?? - values[normalizedFieldName + ":default"] ?? - values[normalizedFieldName]) - : ""; - const valueStr = shouldDisplayValue && !signatureImageSrc - ? resolveDisplayValue(field, rawValue) - : ""; - const isFilled = !!signatureImageSrc || valueStr.trim().length > 0; - - // Get alignment and wrapping from field schema - 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 resolvedFont = resolvePreviewFont(fieldType, field.font); - - 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) { - // Fit in document-space units so visual result stays stable across zoom levels. - const fitted = fitWrappedText({ - text: valueStr, - fontFamily: resolvedFont.canvasFamily, - maxWidth: fitMaxWidthDoc, - maxHeight: fitMaxHeightDoc, - startSize: field.size ?? 11, - lineHeightMult: 1.0, - }); - fontSizeDoc = fitted.fontSize; - lineHeightDoc = fitted.lineHeight; - displayLines = fitted.lines || []; - } else { - // No wrapping - fit in document-space units. - const defaultSize = fieldType === "signature" ? 25 : 11; - const fitted = fitNoWrapText({ - text: valueStr, - fontFamily: resolvedFont.canvasFamily, - maxWidth: fitMaxWidthDoc, - maxHeight: fitMaxHeightDoc, - startSize: field.size ?? defaultSize, - }); - - fontSizeDoc = fitted.fontSize; - lineHeightDoc = fontSizeDoc * 1.0; - displayLines = [fitted.line]; - } - } else { - fontSizeDoc = field.size ?? (fieldType === "signature" ? 25 : 11); - lineHeightDoc = fontSizeDoc * 1.0; - } - - const fontSize = fontSizeDoc * scale; - const lineHeight = lineHeightDoc * scale; - - const isSelected = - selectedFieldId === fieldName || - clickedHighlightFieldId === field.id; - const isContextLabelForField = - !!contextLabelFieldId && - normalizePreviewFieldKey(contextLabelFieldId) === - normalizePreviewFieldKey(fieldName); - - const hasFieldError = !!fieldErrors[fieldName]; - const isFieldValid = isFilled && !hasFieldError; - const isClickable = wetSignatureMode || isOwnedByInitiator; - const borderColor = isClickable - ? isFieldValid - ? "#16a34a" - : "#dc2626" - : "#d1d5db"; - const fillColor = isClickable - ? isFieldValid - ? "rgba(34, 197, 94, 0.2)" - : "rgba(239, 68, 68, 0.2)" - : "transparent"; - const ownerLabel = field.signing_party_id - ? (signingPartyLabelById.get(field.signing_party_id) ?? - "Unassigned") - : "Unassigned"; - const showOwnerTooltip = - !isClickable && - (hoveredFieldId === field.id || - (isTouchInteraction && activeTouchFieldId === field.id)); - const signatureImageWidthPixels = widthPixels * SIGNATURE_IMAGE_OVERFLOW_SCALE; - const signatureImageHeightPixels = heightPixels * SIGNATURE_IMAGE_OVERFLOW_SCALE; - - return ( -
{ - 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, 4)}px`, - height: `${Math.max(heightPixels, 4)}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" - : align_v === "bottom" - ? "flex-end" - : "flex-start", - justifyContent: - align_h === "center" - ? "center" - : align_h === "right" - ? "flex-end" - : "flex-start", - }} - > - {isContextLabelForField && ( -
-
- Updating -
-
-
- )} - {signatureImageSrc ? ( -
- -
- ) : null} - {isFilled && !signatureImageSrc && ( -
- {displayLines.length > 0 ? displayLines.join("\n") : valueStr} -
- )} - {showOwnerTooltip ? ( - - ) : null} -
- ); - })} -
-
- ); -}; - -const AssignedOwnerTooltip = ({ ownerLabel }: { ownerLabel: string }) => ( -
- - Filled by {ownerLabel} - -
-); diff --git a/components/modals/modal-registry.tsx b/components/modals/modal-registry.tsx index ce7819ed..4aa75c76 100644 --- a/components/modals/modal-registry.tsx +++ b/components/modals/modal-registry.tsx @@ -19,7 +19,7 @@ import { import { ApplyModal } from "./components/ApplyModal"; import type { ApplyPayload } from "./components/ApplyModal"; import { MissingRequirementsModal } from "./components/MissingRequirementsModal"; -import { FormPreviewPdfDisplay } from "../features/student/forms/previewer"; +import { FormFillPdfViewer } from "@betterinternship/core/pdf-viewer"; import { IFormSigningParty } from "@betterinternship/core/forms"; import { ApplicationAction } from "@/lib/consts/application"; import { EmployerApplication, Resume } from "@/lib/db/db.types"; @@ -414,10 +414,11 @@ export const useModalRegistry = () => { open( "preview-form-pdf", SlideUpModalLayout, - , { title: "PDF Preview", diff --git a/lib/form-previewer-model.ts b/lib/form-previewer-model.ts deleted file mode 100644 index 8c2d4c64..00000000 --- a/lib/form-previewer-model.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { coerceAnyDate, formatTimestampDateWithoutTime } from "@/lib/utils"; - -const MONTH_NAMES = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -export type PreviewFieldType = "text" | "signature" | "image"; - -export interface PreviewField { - id: string; - field: string; - label: string; - page: number; - x: number; - y: number; - w: number; - h: number; - size?: number; - wrap?: boolean; - align_h?: "left" | "center" | "right"; - align_v?: "top" | "middle" | "bottom"; - font?: string; - type?: PreviewFieldType; - signing_party_id?: string; - source?: string; - prefiller?: unknown; -} - -// Incoming student preview payload shape. -export type PreviewFieldInput = { - _id?: string; - id?: string; - field: string; - label?: string; - page?: number; - x?: number; - y?: number; - w?: number; - h?: number; - size?: number; - wrap?: boolean; - align_h?: "left" | "center" | "right"; - align_v?: "top" | "middle" | "bottom"; - font?: string; - type?: PreviewFieldType; - signing_party_id?: string; - source?: string; - prefiller?: unknown; -}; - -export function toPreviewFields(inputs: PreviewFieldInput[]): PreviewField[] { - return (inputs ?? []) - .filter((input) => input?.field && input.type !== "image") - .map((input, idx) => { - const page = typeof input.page === "number" ? input.page : 1; - const x = typeof input.x === "number" ? input.x : 0; - const y = typeof input.y === "number" ? input.y : 0; - const w = typeof input.w === "number" ? input.w : 0; - const h = typeof input.h === "number" ? input.h : 0; - const id = - (typeof input.id === "string" && input.id) || - (typeof input._id === "string" && input._id) || - `${input.field}:${page}:${x}:${y}:${idx}`; - - return { - id, - field: input.field, - label: input.label || input.field, - page, - x, - y, - w, - h, - size: input.size, - wrap: input.wrap, - align_h: input.align_h, - align_v: input.align_v, - font: input.font, - type: input.type, - signing_party_id: input.signing_party_id, - source: input.source, - prefiller: input.prefiller, - }; - }); -} - -export function groupFieldsByPage(fields: PreviewField[]) { - const byPage = new Map(); - - for (const field of fields) { - const list = byPage.get(field.page) || []; - list.push(field); - byPage.set(field.page, list); - } - - return byPage; -} -type PreviewRefRecord = { name?: string } | null; - -export interface PreviewValueRefs { - get_college?: (id: string | null | undefined) => PreviewRefRecord; - get_department?: (id: string | null | undefined) => PreviewRefRecord; - get_university?: (id: string | null | undefined) => PreviewRefRecord; - to_college_name?: ( - id: string | null | undefined, - def?: string | null, - ) => string | null; - to_department_name?: ( - id: string | null | undefined, - def?: string | null, - ) => string | null; - to_university_name?: ( - id: string | null | undefined, - def?: string | null, - ) => string | null; -} - -export const normalizePreviewFieldKey = (fieldKey: string): string => - String(fieldKey ?? "") - .trim() - .replace(/:default$/i, ""); - -export const resolveAutoPreviewValue = ( - fieldKey: string, - now = new Date(), -): string => { - const normalized = normalizePreviewFieldKey(fieldKey).toLowerCase(); - if (normalized === "auto.current-date") - return formatTimestampDateWithoutTime(now.getTime()); - if (normalized === "auto.current-day") return now.getDate().toString(); - if (normalized === "auto.current-month") - return MONTH_NAMES[now.getMonth()] ?? ""; - - if (normalized === "auto.current-year") return now.getFullYear().toString(); - return ""; -}; - -export const createPreviewDisplayValueResolver = ({ - refs, - user, - nowFactory = () => new Date(), -}: { - refs: PreviewValueRefs; - user: Record | null | undefined; - nowFactory?: () => Date; -}) => { - const getUserString = (key: string): string => { - const value = user?.[key]; - return value == null ? "" : String(value).trim(); - }; - - const resolvePrefillValue = (field: PreviewField): string => { - if (!user) return ""; - - const firstName = getUserString("first_name"); - const middleName = getUserString("middle_name"); - const lastName = getUserString("last_name"); - const fullName = [firstName, middleName, lastName] - .filter(Boolean) - .join(" ") - .replace(/\s+/g, " ") - .trim(); - - const normalizedField = normalizePreviewFieldKey(field.field).toLowerCase(); - const directMap: Record = { - "student.school": getUserString("college"), - "student.college": getUserString("college"), - "student.department": getUserString("department"), - "student.university": getUserString("university"), - "student.first-name": firstName, - "student.middle-name": middleName, - "student.last-name": lastName, - "student.full-name": fullName, - "student-signature": fullName, - "student.phone-number": getUserString("phone_number"), - "student.email": getUserString("email"), - }; - const mapped = directMap[normalizedField]; - if (mapped) return mapped; - - if (typeof field.prefiller === "function") { - try { - const prefiller = field.prefiller as (params: { - user: Record | null | undefined; - }) => unknown; - const value = prefiller({ user }); - return value == null ? "" : String(value).trim(); - } catch { - return ""; - } - } - - if (typeof field.prefiller === "string") { - const match = field.prefiller.match(/user\.([a-zA-Z0-9_]+)/); - if (match?.[1]) return getUserString(match[1]); - } - - return ""; - }; - - const resolveInitiatorFallbackValue = (field: PreviewField): string => { - const owner = String(field.signing_party_id ?? "").toLowerCase(); - const isInitiatorOwned = owner === "initiator" || owner === "student"; - if (!isInitiatorOwned) return ""; - - const source = String(field.source ?? "").toLowerCase(); - const normalizedField = normalizePreviewFieldKey(field.field).toLowerCase(); - const shouldUseAuto = - source === "auto" || normalizedField.startsWith("auto.current-"); - const shouldUsePrefill = - source === "prefill" || - normalizedField === "student.school" || - normalizedField === "student.college" || - normalizedField === "student.department" || - normalizedField === "student.university" || - normalizedField === "student.first-name" || - normalizedField === "student.middle-name" || - normalizedField === "student.last-name" || - normalizedField === "student.full-name" || - normalizedField === "student-signature" || - normalizedField === "student.phone-number" || - normalizedField === "student.email"; - - if (shouldUseAuto) - return resolveAutoPreviewValue(field.field, nowFactory()); - if (shouldUsePrefill) return resolvePrefillValue(field); - return ""; - }; - - const tryResolveRefName = (candidate: string): string | null => { - const college = refs.get_college?.(candidate)?.name; - if (college) return college; - const department = refs.get_department?.(candidate)?.name; - if (department) return department; - const university = refs.get_university?.(candidate)?.name; - if (university) return university; - return null; - }; - - return (field: PreviewField, rawValue: unknown): string => { - const rawString = Array.isArray(rawValue) - ? rawValue.join(", ") - : typeof rawValue === "string" - ? rawValue - : typeof rawValue === "number" - ? String(rawValue) - : ""; - const fallbackValue = rawString.trim() - ? "" - : resolveInitiatorFallbackValue(field); - const value = rawString || fallbackValue; - if (!value) return ""; - - const trimmedValue = value.trim(); - if (!trimmedValue) return ""; - - const normalizedField = normalizePreviewFieldKey(field.field).toLowerCase(); - if (normalizedField === "auto.current-date") { - const dateMs = coerceAnyDate(trimmedValue); - if (dateMs) return formatTimestampDateWithoutTime(dateMs); - } - - const loweredFieldName = field.field.toLowerCase(); - if ( - loweredFieldName.includes("college") && - typeof refs.to_college_name === "function" - ) { - return refs.to_college_name(trimmedValue, trimmedValue) ?? trimmedValue; - } - if ( - loweredFieldName.includes("department") && - typeof refs.to_department_name === "function" - ) { - return ( - refs.to_department_name(trimmedValue, trimmedValue) ?? trimmedValue - ); - } - if ( - loweredFieldName.includes("university") && - typeof refs.to_university_name === "function" - ) { - return ( - refs.to_university_name(trimmedValue, trimmedValue) ?? trimmedValue - ); - } - - const directRefMatch = tryResolveRefName(trimmedValue); - if (directRefMatch) return directRefMatch; - - return value; - }; -}; diff --git a/lib/form-previewer-rendering.ts b/lib/form-previewer-rendering.ts deleted file mode 100644 index 47f46f34..00000000 --- a/lib/form-previewer-rendering.ts +++ /dev/null @@ -1,459 +0,0 @@ -import type { PreviewFieldType } from "@/lib/form-previewer-model"; - -const DEFAULT_TEXT_FONT_FILE = "Roboto-Regular.ttf"; -const DEFAULT_SIGNATURE_FONT_FILE = "megastina.regular.ttf"; -const PREVIEW_FONT_STYLE_ID = "previewer-font-face-style"; -const PREVIEW_GOOGLE_FONT_LINK_ID = "previewer-google-font-link"; - -const LOCAL_PREVIEW_FONTS = [ - { family: "MegastinaPreview", file: "megastina.regular.ttf" }, - { family: "HighEmpathyPreview", file: "high-empathy.regular.ttf" }, - { family: "RoyaltyFreePreview", file: "royalty-free.regular.ttf" }, - { family: "TestimoniaPreview", file: "Testimonia.ttf" }, - { family: "TheSignaturePreview", file: "thesignature.regular.ttf" }, - { family: "ItaliannoPreview", file: "Italianno-Regular.ttf" }, - { family: "RobotoPreview", file: "Roboto-Regular.ttf" }, -] as const; - -export type ResolvedPreviewFont = { - cssFamily: string; - canvasFamily: string; - fontWeight: "400"; -}; - -const normalizeFontToken = (value?: string | null) => - String(value ?? "") - .trim() - .toLowerCase(); - -export const resolvePreviewFont = ( - fieldType?: PreviewFieldType, - fieldFont?: string | null, -): ResolvedPreviewFont => { - const fallbackToken = - fieldType === "signature" - ? DEFAULT_SIGNATURE_FONT_FILE - : DEFAULT_TEXT_FONT_FILE; - const token = normalizeFontToken(fieldFont || fallbackToken); - const isSignatureField = fieldType === "signature"; - - if (token.includes("megastina")) { - return { - cssFamily: "MegastinaPreview, cursive", - canvasFamily: "MegastinaPreview, cursive", - fontWeight: "400", - }; - } - - if (token.includes("high empathy") || token.includes("high-empathy")) { - return { - cssFamily: "HighEmpathyPreview, cursive", - canvasFamily: "HighEmpathyPreview, cursive", - fontWeight: "400", - }; - } - - if (token.includes("royalty free") || token.includes("royalty-free")) { - return { - cssFamily: "RoyaltyFreePreview, cursive", - canvasFamily: "RoyaltyFreePreview, cursive", - fontWeight: "400", - }; - } - - if (token.includes("testimonia")) { - return { - cssFamily: "TestimoniaPreview, cursive", - canvasFamily: "TestimoniaPreview, cursive", - fontWeight: "400", - }; - } - - if (token.includes("the signature") || token.includes("thesignature")) { - return { - cssFamily: "TheSignaturePreview, cursive", - canvasFamily: "TheSignaturePreview, cursive", - fontWeight: "400", - }; - } - - // Signature fields should always use a signature-style font family. - // If metadata carries a generic text font token (e.g. Roboto), use Megastina fallback. - if (isSignatureField) { - return { - cssFamily: "MegastinaPreview, cursive", - canvasFamily: "MegastinaPreview, cursive", - fontWeight: "400", - }; - } - - if (token.includes("italianno")) { - return { - cssFamily: "ItaliannoPreview, cursive", - canvasFamily: "ItaliannoPreview, cursive", - fontWeight: "400", - }; - } - - if (token.includes("roboto")) { - return { - cssFamily: "RobotoPreview, Roboto, sans-serif", - canvasFamily: "RobotoPreview, Roboto, sans-serif", - fontWeight: "400", - }; - } - - if (token.includes("arial")) { - return { - cssFamily: "Arial, sans-serif", - canvasFamily: "Arial, sans-serif", - fontWeight: "400", - }; - } - - if (token.includes("times")) { - return { - cssFamily: '"Times New Roman", serif', - canvasFamily: '"Times New Roman", serif', - fontWeight: "400", - }; - } - - if (token.includes("ubuntu mono")) { - return { - cssFamily: '"Ubuntu Mono", monospace', - canvasFamily: '"Ubuntu Mono", monospace', - fontWeight: "400", - }; - } - - return { - cssFamily: "RobotoPreview, Roboto, sans-serif", - canvasFamily: "RobotoPreview, Roboto, sans-serif", - fontWeight: "400", - }; -}; - -export const ensurePreviewFontsLoaded = () => { - if (typeof window === "undefined") return; - - if (!document.getElementById(PREVIEW_GOOGLE_FONT_LINK_ID)) { - const link = document.createElement("link"); - link.id = PREVIEW_GOOGLE_FONT_LINK_ID; - link.href = - "https://fonts.googleapis.com/css2?family=Roboto:wght@400&family=Italianno&display=block"; - link.rel = "stylesheet"; - document.head.appendChild(link); - } - - if (!document.getElementById(PREVIEW_FONT_STYLE_ID)) { - const style = document.createElement("style"); - style.id = PREVIEW_FONT_STYLE_ID; - style.textContent = LOCAL_PREVIEW_FONTS.map( - ({ family, file }) => ` -@font-face { - font-family: "${family}"; - src: url("/fonts/${file}") format("truetype"); - font-weight: 400; - font-style: normal; - font-display: block; -}`, - ).join("\n"); - document.head.appendChild(style); - } - - if ("fonts" in document) { - document.fonts.ready.catch(() => { - // Font loading failure should not block preview rendering. - }); - } -}; - -let sharedCanvas: HTMLCanvasElement | null = null; -let sharedCtx: CanvasRenderingContext2D | null = null; - -const fitNoWrapCache = new Map< - string, - { - fontSize: number; - line: string; - ascent: number; - descent: number; - height: number; - } ->(); - -function getSharedContext(): CanvasRenderingContext2D | null { - if (!sharedCanvas) { - sharedCanvas = document.createElement("canvas"); - sharedCtx = sharedCanvas.getContext("2d"); - } - return sharedCtx; -} - -function measureTextWidth( - text: string, - fontSize: number, - fontFamily: string, -): number { - const ctx = getSharedContext(); - if (!ctx) return 0; - ctx.font = `${fontSize}px ${fontFamily}`; - return ctx.measureText(text).width; -} - -function getFontMetricsAtSize(fontSize: number, fontFamily: string) { - const ctx = getSharedContext(); - if (ctx) { - ctx.font = `${fontSize}px ${fontFamily}`; - const metrics = ctx.measureText("Ag"); - if ( - Number.isFinite(metrics.actualBoundingBoxAscent) && - Number.isFinite(metrics.actualBoundingBoxDescent) - ) { - const ascent = metrics.actualBoundingBoxAscent; - const descent = -metrics.actualBoundingBoxDescent; - return { - ascent, - descent, - height: ascent - descent, - }; - } - } - - const ascent = fontSize * 0.8; - const descent = -fontSize * 0.2; - return { - ascent, - descent, - height: ascent - descent, - }; -} - -function wrapText({ - text, - fontSize, - fontFamily, - maxWidth, - zoom = 1, -}: { - text: string; - fontSize: number; - fontFamily: string; - maxWidth: number; - zoom?: number; -}): string[] { - const paragraphs = String(text ?? "").split(/\r?\n/); - const lines: string[] = []; - const measure = (s: string) => - measureTextWidth(s, fontSize, fontFamily) * zoom; - - const breakLongWord = (word: string): string[] => { - const parts: string[] = []; - let cur = ""; - for (const ch of word) { - const next = cur + ch; - if (cur && measure(next) > maxWidth) { - parts.push(cur); - cur = ch; - } else { - cur = next; - } - } - if (cur) parts.push(cur); - return parts; - }; - - for (const para of paragraphs) { - const trimmed = para.trim(); - if (!trimmed) { - lines.push(""); - continue; - } - - const words = trimmed.split(/\s+/); - let current = ""; - - for (const w of words) { - const candidate = current ? `${current} ${w}` : w; - - if (measure(candidate) <= maxWidth) { - current = candidate; - continue; - } - - if (current) { - lines.push(current); - current = ""; - } - - if (measure(w) <= maxWidth) { - current = w; - } else { - const broken = breakLongWord(w); - for (let i = 0; i < broken.length; i++) { - if (i === broken.length - 1) current = broken[i]; - else lines.push(broken[i]); - } - } - } - - if (current) lines.push(current); - } - - return lines; -} - -function layoutWrappedBlock({ - text, - fontSize, - fontFamily, - lineHeight, - maxWidth, - zoom = 1, -}: { - text: string; - fontSize: number; - fontFamily: string; - lineHeight: number; - maxWidth: number; - zoom?: number; -}) { - const { ascent, descent } = getFontMetricsAtSize(fontSize, fontFamily); - const lines = wrapText({ text, fontSize, fontFamily, maxWidth, zoom }); - const n = lines.length; - const blockHeight = (n > 0 ? (n - 1) * lineHeight : 0) + (ascent - descent); - return { lines, ascent, descent, blockHeight }; -} - -export function fitWrappedText({ - text, - fontFamily, - maxWidth, - maxHeight, - startSize, - lineHeightMult = 1.2, - zoom = 1, -}: { - text: string; - fontFamily: string; - maxWidth: number; - maxHeight: number; - startSize: number; - lineHeightMult?: number; - zoom?: number; -}) { - const fits = (size: number): boolean => { - const lh = size * lineHeightMult; - const { blockHeight } = layoutWrappedBlock({ - text, - fontSize: size, - fontFamily, - lineHeight: lh, - maxWidth, - zoom, - }); - return blockHeight <= maxHeight + 1e-6; - }; - - if (fits(startSize)) { - const lh = startSize * lineHeightMult; - const laid = layoutWrappedBlock({ - text, - fontSize: startSize, - fontFamily, - lineHeight: lh, - maxWidth, - zoom, - }); - return { fontSize: startSize, lineHeight: lh, ...laid }; - } - - let hi = startSize; - let lo = startSize; - while (!fits(lo)) { - lo /= 2; - if (lo < 0.1) break; - } - - for (let i = 0; i < 22; i++) { - const mid = (lo + hi) / 2; - if (fits(mid)) lo = mid; - else hi = mid; - } - - const bestSize = lo; - const bestLineHeight = bestSize * lineHeightMult; - const laid = layoutWrappedBlock({ - text, - fontSize: bestSize, - fontFamily, - lineHeight: bestLineHeight, - maxWidth, - zoom, - }); - return { fontSize: bestSize, lineHeight: bestLineHeight, ...laid }; -} - -export function fitNoWrapText({ - text, - fontFamily, - maxWidth, - maxHeight, - startSize, -}: { - text: string; - fontFamily: string; - maxWidth: number; - maxHeight: number; - startSize: number; -}) { - const line = String(text ?? "").replace(/\r?\n/g, " "); - const cacheKey = `${line}|${fontFamily}|${maxWidth}|${maxHeight}|${startSize}`; - if (fitNoWrapCache.has(cacheKey)) { - return fitNoWrapCache.get(cacheKey)!; - } - - const ctx = getSharedContext(); - - const fits = (size: number): boolean => { - if (!ctx) return false; - ctx.font = `${size}px ${fontFamily}`; - const w = ctx.measureText(line).width; - const { height } = getFontMetricsAtSize(size, fontFamily); - return w <= maxWidth + 1e-6 && height <= maxHeight + 1e-6; - }; - - if (fits(startSize)) { - const { ascent, descent, height } = getFontMetricsAtSize( - startSize, - fontFamily, - ); - const result = { fontSize: startSize, line, ascent, descent, height }; - fitNoWrapCache.set(cacheKey, result); - return result; - } - - let hi = startSize; - let lo = startSize; - while (!fits(lo)) { - lo /= 2; - if (lo < 0.1) break; - } - - for (let i = 0; i < 22; i++) { - const mid = (lo + hi) / 2; - if (fits(mid)) lo = mid; - else hi = mid; - } - - const bestSize = lo; - const { ascent, descent, height } = getFontMetricsAtSize( - bestSize, - fontFamily, - ); - const result = { fontSize: bestSize, line, ascent, descent, height }; - fitNoWrapCache.set(cacheKey, result); - - return result; -} diff --git a/package.json b/package.json index ab9d5c2f..df1fa875 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "@betterinternship/components": "1.5.28", - "@betterinternship/core": "^2.12.2", + "@betterinternship/core": "^2.14.1", + "pdfjs-dist": "^4", "@betterinternship/schema": "^0.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", From 70caa70d5baf4944da3baa84b2cbc43356eb5ada Mon Sep 17 00:00:00 2001 From: Mo David Date: Mon, 8 Jun 2026 21:35:21 +0800 Subject: [PATCH 15/19] feat: use signed url to access bucket documents --- .../forms/components/FormActionButtons.tsx | 8 +- .../forms/components/FormSigningLayout.tsx | 6 +- components/forms/FormLog.tsx | 6 +- components/forms/previewer.tsx | 5 +- lib/signed-url.ts | 128 ++++++++++++++++++ 5 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 lib/signed-url.ts diff --git a/app/student/forms/components/FormActionButtons.tsx b/app/student/forms/components/FormActionButtons.tsx index 7cfb166e..34ed9d74 100644 --- a/app/student/forms/components/FormActionButtons.tsx +++ b/app/student/forms/components/FormActionButtons.tsx @@ -4,6 +4,7 @@ import { useFormRendererContext } from "@/components/features/student/forms/form import { Button } from "@/components/ui/button"; import useModalRegistry from "@/components/modals/modal-registry"; import { cn } from "@/lib/utils"; +import { resolveSignedUrl } from "@/lib/signed-url"; export const FormActionButtons = ({ handleSignViaBetterInternship, @@ -30,11 +31,10 @@ export const FormActionButtons = ({ size="lg" className="w-full text-lg sm:w-auto" variant="outline" - onClick={() => { + onClick={async () => { if (form.document.url) { - modalRegistry.previewFormPdf.open({ - documentUrl: form.document.url, - }); + const resolved = await resolveSignedUrl(form.document.url); + modalRegistry.previewFormPdf.open({ documentUrl: resolved }); } else { alert("No document url provided for preview."); } diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx index 80a54e41..fca3d39e 100644 --- a/app/student/forms/components/FormSigningLayout.tsx +++ b/app/student/forms/components/FormSigningLayout.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, LucideClipboardCheck, MailWarningIcon } from "lucide-react"; import { FormFillPdfViewer } from "@betterinternship/core/pdf-viewer"; +import { useSignedUrl } from "@/lib/signed-url"; import { FormFillerRenderer } from "@/components/features/student/forms/FormFillerRenderer"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -115,6 +116,7 @@ export function FormSigningLayout({ noEsign, onBack, }: FlowTestSigningLayoutProps) { + const { url: resolvedDocumentUrl } = useSignedUrl(documentUrl ?? ""); const form = useFormRendererContext(); const profile = useProfileData(); const refs = useDbRefs(); @@ -870,11 +872,11 @@ export function FormSigningLayout({
)} - {documentUrl ? ( + {resolvedDocumentUrl ? ( <> { + const handleDownload = async () => { if (!downloadUrl) return; try { setDownloading(true); + const resolved = await resolveSignedUrl(downloadUrl); const a = document.createElement("a"); - a.href = downloadUrl!; + a.href = resolved; a.download = ""; a.target = "_blank"; a.rel = "noopener noreferrer"; diff --git a/components/forms/previewer.tsx b/components/forms/previewer.tsx index 2576b029..9e6a14b6 100644 --- a/components/forms/previewer.tsx +++ b/components/forms/previewer.tsx @@ -11,6 +11,7 @@ import "./react-pdf-highlighter.css"; import { Loader } from "@/components/ui/loader"; import { cn } from "@/lib/utils"; import { createPortal } from "react-dom"; +import { useSignedUrl } from "@/lib/signed-url"; import { AreaHighlight, Comment, @@ -60,6 +61,8 @@ export const DocumentRenderer = ({ transform: DocumentObjectTransform, ) => void; }) => { + const { url: resolvedUrl } = useSignedUrl(documentUrl); + // Triggered when highlighting is finished const onSelectionFinished = (position: ScaledPosition, content: Content) => { const newHighlight: DocumentHighlight = { @@ -131,7 +134,7 @@ export const DocumentRenderer = ({ return (
- }> + }> {(pdfDocument) => ( + typeof url === "string" && url.startsWith(BUCKET_PREFIX); + +type CacheEntry = { signedUrl: string; expiresAt: number }; +const cache = new Map(); +const inflight = new Map>(); + +const TTL_MS = 28 * 60 * 1000; // 28 min (server signs for 30 min); we make it less so it doesn't 403 on expiry + +const resolveFromServer = async ( + urls: string[], +): Promise> => { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + const res = await fetch(`${apiUrl}/api/users/resolve-url`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ urls }), + }); + if (!res.ok) return {}; + const data = (await res.json()) as { urls?: Record }; + return data.urls ?? {}; +}; + +export const resolveSignedUrl = (url: string): Promise => { + if (!isBucketUrl(url)) return Promise.resolve(url); + + const cached = cache.get(url); + if (cached && cached.expiresAt > Date.now()) + return Promise.resolve(cached.signedUrl); + + const existing = inflight.get(url); + if (existing) return existing; + + const promise = resolveFromServer([url]) + .then((result) => { + const signedUrl = result[url] ?? url; + cache.set(url, { signedUrl, expiresAt: Date.now() + TTL_MS }); + inflight.delete(url); + return signedUrl; + }) + .catch(() => { + inflight.delete(url); + return url; + }); + inflight.set(url, promise); + return promise; +}; + +export const resolveSignedUrls = async ( + urls: string[], +): Promise> => { + const now = Date.now(); + const result: Record = {}; + + const toFetch: string[] = []; + for (const url of urls) { + if (!isBucketUrl(url)) { + result[url] = url; + continue; + } + const cached = cache.get(url); + if (cached && cached.expiresAt > now) { + result[url] = cached.signedUrl; + } else { + toFetch.push(url); + } + } + + if (toFetch.length) { + const serverResults = await resolveFromServer(toFetch).catch( + () => ({}) as Record, + ); + for (const url of toFetch) { + const signed = serverResults[url] ?? url; + cache.set(url, { signedUrl: signed, expiresAt: now + TTL_MS }); + result[url] = signed; + } + } + + return result; +}; + +export const useSignedUrl = (url: string) => { + const [signedUrl, setSignedUrl] = useState(url); + const [loading, setLoading] = useState(isBucketUrl(url)); + + useEffect(() => { + if (!url) { + setSignedUrl(url); + setLoading(false); + return; + } + setLoading(true); + void resolveSignedUrl(url).then((resolved) => { + setSignedUrl(resolved); + setLoading(false); + }); + }, [url]); + + return { url: signedUrl, loading }; +}; + +export const useSignedUrls = (urls: string[]) => { + const key = urls.join("\0"); + const [resolved, setResolved] = useState>(() => + Object.fromEntries(urls.map((u) => [u, u])), + ); + const [loading, setLoading] = useState(() => urls.some(isBucketUrl)); + + useEffect(() => { + if (!urls.length) return; + setLoading(true); + void resolveSignedUrls(urls).then((result) => { + setResolved(result); + setLoading(false); + }); + }, [key]); + + return { urls: resolved, loading }; +}; From a3ca8c66ab4df3a11f52629def9f54c04ac45782 Mon Sep 17 00:00:00 2001 From: anaj00 Date: Wed, 10 Jun 2026 02:44:24 +0800 Subject: [PATCH 16/19] chore: fixed autofill spilling into the form filler, and inconsistent name --- .../forms/components/FormSigningLayout.tsx | 20 ++++++++++++++++++- hooks/use-my-autofill.tsx | 12 ++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx index fca3d39e..b6960f97 100644 --- a/app/student/forms/components/FormSigningLayout.tsx +++ b/app/student/forms/components/FormSigningLayout.tsx @@ -195,6 +195,13 @@ export function FormSigningLayout({ }); return ownerMap; }, [form.formMetadata, form.formName]); + const initiatorFieldNames = useMemo(() => { + const names = new Set(); + fieldOwnerByName.forEach((partyId, fieldName) => { + if (partyId === "initiator") names.add(fieldName); + }); + return names; + }, [fieldOwnerByName]); const formFilloutProcess = useFormFilloutProcessRunner(); const fromMe = useMemo( () => @@ -346,6 +353,17 @@ export function FormSigningLayout({ } return resolved; }, [previewValuesWithDerived, refs]); + const previewValuesForViewer = useMemo(() => { + if (noEsign) return previewValuesResolved; + const filtered: Record = {}; + for (const key of Object.keys(previewValuesResolved)) { + const normalized = key.replace(/:default$/i, "").replace(/:auto$/i, ""); + if (initiatorFieldNames.has(normalized) || initiatorFieldNames.has(key)) { + filtered[key] = previewValuesResolved[key]; + } + } + return filtered; + }, [previewValuesResolved, initiatorFieldNames, noEsign]); const wetSignatureHiddenFieldNames = useMemo( () => (noEsign ? getSignatureDerivedFieldNames(form.formMetadata) : []), [form.formMetadata, noEsign], @@ -878,7 +896,7 @@ export function FormSigningLayout({ key={isMobileLayout ? "mobile-preview" : "desktop-preview"} documentUrl={resolvedDocumentUrl} blocks={previewBlocksForViewer} - values={previewValuesResolved} + values={previewValuesForViewer} fieldErrors={formFiller.errors} selectionTick={selectionTick} autoScrollToSelectedField={ diff --git a/hooks/use-my-autofill.tsx b/hooks/use-my-autofill.tsx index 23c4a01a..ec2b1858 100644 --- a/hooks/use-my-autofill.tsx +++ b/hooks/use-my-autofill.tsx @@ -25,7 +25,10 @@ export const useMyAutofill = () => { string, Record >; - const autofillValues: FormValues = isFreshFormsModeEnabled + const initiatorFieldSet = new Set(form.fields.map((f) => f.field)); + + // Only keep autofill values for initiator-owned fields + const rawValues: FormValues = isFreshFormsModeEnabled ? {} : { ...(internshipMoaFields?.base ?? {}), @@ -33,6 +36,13 @@ export const useMyAutofill = () => { ...(internshipMoaFields?.[form.formName] ?? {}), }; + const autofillValues: FormValues = {}; + for (const key of Object.keys(rawValues)) { + if (initiatorFieldSet.has(key)) { + autofillValues[key] = rawValues[key]; + } + } + // Populate with prefillers as well for (const field of form.fields) { if (field.prefiller) { From 0b4edcb6dd65142f699780f98457207325075df4 Mon Sep 17 00:00:00 2001 From: anaj00 Date: Wed, 10 Jun 2026 03:02:23 +0800 Subject: [PATCH 17/19] refactor: remove initiatorFieldNames memoization and adjust filtering logic --- .../forms/components/FormSigningLayout.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx index b6960f97..8c3d2005 100644 --- a/app/student/forms/components/FormSigningLayout.tsx +++ b/app/student/forms/components/FormSigningLayout.tsx @@ -195,13 +195,6 @@ export function FormSigningLayout({ }); return ownerMap; }, [form.formMetadata, form.formName]); - const initiatorFieldNames = useMemo(() => { - const names = new Set(); - fieldOwnerByName.forEach((partyId, fieldName) => { - if (partyId === "initiator") names.add(fieldName); - }); - return names; - }, [fieldOwnerByName]); const formFilloutProcess = useFormFilloutProcessRunner(); const fromMe = useMemo( () => @@ -358,18 +351,21 @@ export function FormSigningLayout({ const filtered: Record = {}; for (const key of Object.keys(previewValuesResolved)) { const normalized = key.replace(/:default$/i, "").replace(/:auto$/i, ""); - if (initiatorFieldNames.has(normalized) || initiatorFieldNames.has(key)) { - filtered[key] = previewValuesResolved[key]; - } + const owner = + fieldOwnerByName.get(normalized) ?? fieldOwnerByName.get(key); + if (owner !== undefined && owner !== "initiator") continue; + filtered[key] = previewValuesResolved[key]; } return filtered; - }, [previewValuesResolved, initiatorFieldNames, noEsign]); + }, [previewValuesResolved, fieldOwnerByName, noEsign]); const wetSignatureHiddenFieldNames = useMemo( () => (noEsign ? getSignatureDerivedFieldNames(form.formMetadata) : []), [form.formMetadata, noEsign], ); const previewBlocksForViewer = useMemo(() => { - const hiddenSet = new Set(wetSignatureHiddenFieldNames.map(normalizeFieldName)); + const hiddenSet = new Set( + wetSignatureHiddenFieldNames.map(normalizeFieldName), + ); const raw = noEsign ? previewKeyedFields.filter( (field) => @@ -379,7 +375,10 @@ export function FormSigningLayout({ : previewKeyedFields; return raw.map((field) => ({ ...field, - id: field.id || field._id || `${field.field}:${field.page}:${field.x}:${field.y}`, + id: + field.id || + field._id || + `${field.field}:${field.page}:${field.x}:${field.y}`, })); }, [previewKeyedFields, noEsign, wetSignatureHiddenFieldNames]); From a8d3046ebb1d4330cb66ed63c77d2ffa281ff26c Mon Sep 17 00:00:00 2001 From: anaj00 Date: Wed, 10 Jun 2026 03:15:19 +0800 Subject: [PATCH 18/19] chore: add our bucket to image origin --- next.config.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index fe86bf03..cafb7acf 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -24,7 +24,10 @@ const connectOrigins = apiUrls .filter(Boolean) .join(" "); -const imageOrigins = apiUrls +const imageOrigins = [ + ...apiUrls, + "https://storage.googleapis.com", +] .map((url) => { try { return new URL(url).origin; From 32139715a8e310f2e8a78ddc8891438266233e1e Mon Sep 17 00:00:00 2001 From: anaj00 Date: Thu, 11 Jun 2026 11:58:13 +0800 Subject: [PATCH 19/19] chore: update signature field prompt and disable name input --- .../student/forms/SignatureFieldRenderer.tsx | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/components/features/student/forms/SignatureFieldRenderer.tsx b/components/features/student/forms/SignatureFieldRenderer.tsx index 001240f5..bf67713f 100644 --- a/components/features/student/forms/SignatureFieldRenderer.tsx +++ b/components/features/student/forms/SignatureFieldRenderer.tsx @@ -330,15 +330,21 @@ export const SignatureFieldRenderer = ({

- Enter your full legal name, then choose one signature method. + Full name auto-filled from your profile.

- onBlur?.(value)} - /> +
+ onBlur?.(value)} + disabled + /> +