diff --git a/scripts/archive/seed-story-red-rock.ts b/scripts/archive/seed-story-red-rock.ts
index 6f2e5ab..d0c83a1 100644
--- a/scripts/archive/seed-story-red-rock.ts
+++ b/scripts/archive/seed-story-red-rock.ts
@@ -35,7 +35,8 @@ async function main() {
.values({
studyId: study.id,
name: "Story: Red Rock",
- description: "A story about a red rock on Mars with comprehension check and branching.",
+ description:
+ "A story about a red rock on Mars with comprehension check and branching.",
version: 1,
status: "draft",
robotId: robot.id,
@@ -53,102 +54,212 @@ async function main() {
const checkId = uuidv4();
// Step 1: The Hook
- const [step1] = await db.insert(schema.steps).values({
- experimentId: experiment.id,
- name: "The Hook",
- type: "wizard",
- orderIndex: 0,
- }).returning();
+ const [step1] = await db
+ .insert(schema.steps)
+ .values({
+ experimentId: experiment.id,
+ name: "The Hook",
+ type: "wizard",
+ orderIndex: 0,
+ })
+ .returning();
// Step 2: The Narrative
- const [step2] = await db.insert(schema.steps).values({
- experimentId: experiment.id,
- name: "The Narrative",
- type: "wizard",
- orderIndex: 1,
- }).returning();
+ const [step2] = await db
+ .insert(schema.steps)
+ .values({
+ experimentId: experiment.id,
+ name: "The Narrative",
+ type: "wizard",
+ orderIndex: 1,
+ })
+ .returning();
// Step 3: Comprehension Check (Conditional)
- const [step3] = await db.insert(schema.steps).values({
- id: checkId,
- experimentId: experiment.id,
- name: "Comprehension Check",
- type: "conditional",
- orderIndex: 2,
- conditions: {
- variable: "last_wizard_response",
- options: [
- { label: "Answer: Red (Correct)", value: "Red", variant: "default", nextStepId: branchAId },
- { label: "Answer: Other (Incorrect)", value: "Incorrect", variant: "destructive", nextStepId: branchBId }
- ]
- }
- }).returning();
+ const [step3] = await db
+ .insert(schema.steps)
+ .values({
+ id: checkId,
+ experimentId: experiment.id,
+ name: "Comprehension Check",
+ type: "conditional",
+ orderIndex: 2,
+ conditions: {
+ variable: "last_wizard_response",
+ options: [
+ {
+ label: "Answer: Red (Correct)",
+ value: "Red",
+ variant: "default",
+ nextStepId: branchAId,
+ },
+ {
+ label: "Answer: Other (Incorrect)",
+ value: "Incorrect",
+ variant: "destructive",
+ nextStepId: branchBId,
+ },
+ ],
+ },
+ })
+ .returning();
// Step 4: Branch A (Correct)
- const [step4] = await db.insert(schema.steps).values({
- id: branchAId,
- experimentId: experiment.id,
- name: "Branch A: Correct Response",
- type: "wizard",
- orderIndex: 3,
- conditions: { nextStepId: conclusionId } // SKIP BRANCH B
- }).returning();
+ const [step4] = await db
+ .insert(schema.steps)
+ .values({
+ id: branchAId,
+ experimentId: experiment.id,
+ name: "Branch A: Correct Response",
+ type: "wizard",
+ orderIndex: 3,
+ conditions: { nextStepId: conclusionId }, // SKIP BRANCH B
+ })
+ .returning();
// Step 5: Branch B (Incorrect)
- const [step5] = await db.insert(schema.steps).values({
- id: branchBId,
- experimentId: experiment.id,
- name: "Branch B: Incorrect Response",
- type: "wizard",
- orderIndex: 4,
- conditions: { nextStepId: conclusionId }
- }).returning();
+ const [step5] = await db
+ .insert(schema.steps)
+ .values({
+ id: branchBId,
+ experimentId: experiment.id,
+ name: "Branch B: Incorrect Response",
+ type: "wizard",
+ orderIndex: 4,
+ conditions: { nextStepId: conclusionId },
+ })
+ .returning();
// Step 6: Conclusion
- const [step6] = await db.insert(schema.steps).values({
- id: conclusionId,
- experimentId: experiment.id,
- name: "Conclusion",
- type: "wizard",
- orderIndex: 5,
- }).returning();
+ const [step6] = await db
+ .insert(schema.steps)
+ .values({
+ id: conclusionId,
+ experimentId: experiment.id,
+ name: "Conclusion",
+ type: "wizard",
+ orderIndex: 5,
+ })
+ .returning();
// 4. Create Actions
// The Hook
await db.insert(schema.actions).values([
- { stepId: step1!.id, name: "Say Hello", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "Hello! Are you ready for a story?" } },
- { stepId: step1!.id, name: "Wave", type: "nao6-ros2.move_arm", orderIndex: 1, parameters: { arm: "right", shoulder_pitch: 0.5 } }
+ {
+ stepId: step1!.id,
+ name: "Say Hello",
+ type: "nao6-ros2.say_text",
+ orderIndex: 0,
+ parameters: { text: "Hello! Are you ready for a story?" },
+ },
+ {
+ stepId: step1!.id,
+ name: "Wave",
+ type: "nao6-ros2.move_arm",
+ orderIndex: 1,
+ parameters: { arm: "right", shoulder_pitch: 0.5 },
+ },
]);
// The Narrative
await db.insert(schema.actions).values([
- { stepId: step2!.id, name: "The Story", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "Once, a traveler went to Mars. He found a bright red rock that glowed." } },
- { stepId: step2!.id, name: "Look Left", type: "nao6-ros2.turn_head", orderIndex: 1, parameters: { yaw: 0.5, speed: 0.3 } },
- { stepId: step2!.id, name: "Look Right", type: "nao6-ros2.turn_head", orderIndex: 2, parameters: { yaw: -0.5, speed: 0.3 } }
+ {
+ stepId: step2!.id,
+ name: "The Story",
+ type: "nao6-ros2.say_text",
+ orderIndex: 0,
+ parameters: {
+ text: "Once, a traveler went to Mars. He found a bright red rock that glowed.",
+ },
+ },
+ {
+ stepId: step2!.id,
+ name: "Look Left",
+ type: "nao6-ros2.turn_head",
+ orderIndex: 1,
+ parameters: { yaw: 0.5, speed: 0.3 },
+ },
+ {
+ stepId: step2!.id,
+ name: "Look Right",
+ type: "nao6-ros2.turn_head",
+ orderIndex: 2,
+ parameters: { yaw: -0.5, speed: 0.3 },
+ },
]);
// Comprehension Check
await db.insert(schema.actions).values([
- { stepId: step3!.id, name: "Ask Color", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "What color was the rock I found on Mars?" } },
- { stepId: step3!.id, name: "Wait for Color", type: "wizard_wait_for_response", orderIndex: 1, parameters: { options: ["Red", "Blue", "Green", "Incorrect"], prompt_text: "What color did the participant say?" } }
+ {
+ stepId: step3!.id,
+ name: "Ask Color",
+ type: "nao6-ros2.say_text",
+ orderIndex: 0,
+ parameters: { text: "What color was the rock I found on Mars?" },
+ },
+ {
+ stepId: step3!.id,
+ name: "Wait for Color",
+ type: "wizard_wait_for_response",
+ orderIndex: 1,
+ parameters: {
+ options: ["Red", "Blue", "Green", "Incorrect"],
+ prompt_text: "What color did the participant say?",
+ },
+ },
]);
// Branch A (Using say_with_emotion)
- await db.insert(schema.actions).values([
- { stepId: step4!.id, name: "Happy Response", type: "nao6-ros2.say_with_emotion", orderIndex: 0, parameters: { text: "Exacty! It was a glowing red rock.", emotion: "happy" } }
- ]);
+ await db
+ .insert(schema.actions)
+ .values([
+ {
+ stepId: step4!.id,
+ name: "Happy Response",
+ type: "nao6-ros2.say_with_emotion",
+ orderIndex: 0,
+ parameters: {
+ text: "Exacty! It was a glowing red rock.",
+ emotion: "happy",
+ },
+ },
+ ]);
// Branch B
await db.insert(schema.actions).values([
- { stepId: step5!.id, name: "Correct them", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "Actually, it was red." } },
- { stepId: step5!.id, name: "Shake Head", type: "nao6-ros2.turn_head", orderIndex: 1, parameters: { yaw: 0.3, speed: 0.5 } }
+ {
+ stepId: step5!.id,
+ name: "Correct them",
+ type: "nao6-ros2.say_text",
+ orderIndex: 0,
+ parameters: { text: "Actually, it was red." },
+ },
+ {
+ stepId: step5!.id,
+ name: "Shake Head",
+ type: "nao6-ros2.turn_head",
+ orderIndex: 1,
+ parameters: { yaw: 0.3, speed: 0.5 },
+ },
]);
// Conclusion
await db.insert(schema.actions).values([
- { stepId: step6!.id, name: "Final Goodbye", type: "nao6-ros2.say_text", orderIndex: 0, parameters: { text: "That is all for today. Goodbye!" } },
- { stepId: step6!.id, name: "Rest", type: "nao6-ros2.move_arm", orderIndex: 1, parameters: { shoulder_pitch: 1.5 } }
+ {
+ stepId: step6!.id,
+ name: "Final Goodbye",
+ type: "nao6-ros2.say_text",
+ orderIndex: 0,
+ parameters: { text: "That is all for today. Goodbye!" },
+ },
+ {
+ stepId: step6!.id,
+ name: "Rest",
+ type: "nao6-ros2.move_arm",
+ orderIndex: 1,
+ parameters: { shoulder_pitch: 1.5 },
+ },
]);
console.log("✅ Seed completed successfully!");
diff --git a/scripts/migrate-add-identifier.ts b/scripts/migrate-add-identifier.ts
index 1d6b34d..0c6246a 100644
--- a/scripts/migrate-add-identifier.ts
+++ b/scripts/migrate-add-identifier.ts
@@ -5,21 +5,27 @@ async function migrate() {
console.log("Adding identifier column to hs_plugin...");
try {
- await db.execute(sql`ALTER TABLE hs_plugin ADD COLUMN identifier varchar(100)`);
+ await db.execute(
+ sql`ALTER TABLE hs_plugin ADD COLUMN identifier varchar(100)`,
+ );
console.log("✓ Added identifier column");
} catch (e: any) {
console.log("Column may already exist:", e.message);
}
try {
- await db.execute(sql`UPDATE hs_plugin SET identifier = name WHERE identifier IS NULL`);
+ await db.execute(
+ sql`UPDATE hs_plugin SET identifier = name WHERE identifier IS NULL`,
+ );
console.log("✓ Copied name to identifier");
} catch (e: any) {
console.log("Error copying:", e.message);
}
try {
- await db.execute(sql`ALTER TABLE hs_plugin ADD CONSTRAINT hs_plugin_identifier_unique UNIQUE (identifier)`);
+ await db.execute(
+ sql`ALTER TABLE hs_plugin ADD CONSTRAINT hs_plugin_identifier_unique UNIQUE (identifier)`,
+ );
console.log("✓ Added unique constraint");
} catch (e: any) {
console.log("Constraint may already exist:", e.message);
diff --git a/src/app/(dashboard)/studies/[id]/forms/page.tsx b/src/app/(dashboard)/studies/[id]/forms/page.tsx
index 79e437b..f04e6e3 100644
--- a/src/app/(dashboard)/studies/[id]/forms/page.tsx
+++ b/src/app/(dashboard)/studies/[id]/forms/page.tsx
@@ -3,12 +3,20 @@
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { notFound } from "next/navigation";
-import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
import {
- EntityView,
- EntityViewHeader,
- EntityViewSection,
- EmptyState,
+ FileText,
+ Loader2,
+ Plus,
+ Download,
+ Edit2,
+ Eye,
+ Save,
+} from "lucide-react";
+import {
+ EntityView,
+ EntityViewHeader,
+ EntityViewSection,
+ EmptyState,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button";
@@ -16,302 +24,346 @@ import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { toast } from "sonner";
import { PageHeader } from "~/components/ui/page-header";
-import { useEditor, EditorContent } from '@tiptap/react';
-import StarterKit from '@tiptap/starter-kit';
-import { Markdown } from 'tiptap-markdown';
-import { Table } from '@tiptap/extension-table';
-import { TableRow } from '@tiptap/extension-table-row';
-import { TableCell } from '@tiptap/extension-table-cell';
-import { TableHeader } from '@tiptap/extension-table-header';
-import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
+import { useEditor, EditorContent } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import { Markdown } from "tiptap-markdown";
+import { Table } from "@tiptap/extension-table";
+import { TableRow } from "@tiptap/extension-table-row";
+import { TableCell } from "@tiptap/extension-table-cell";
+import { TableHeader } from "@tiptap/extension-table-header";
+import {
+ Bold,
+ Italic,
+ List,
+ ListOrdered,
+ Heading1,
+ Heading2,
+ Quote,
+ Table as TableIcon,
+} from "lucide-react";
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
const Toolbar = ({ editor }: { editor: any }) => {
- if (!editor) {
- return null;
- }
+ if (!editor) {
+ return null;
+ }
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
};
interface StudyFormsPageProps {
- params: Promise<{
- id: string;
- }>;
+ params: Promise<{
+ id: string;
+ }>;
}
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
- const { data: session } = useSession();
- const utils = api.useUtils();
- const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
- const [editorTarget, setEditorTarget] = useState("");
+ const { data: session } = useSession();
+ const utils = api.useUtils();
+ const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
+ null,
+ );
+ const [editorTarget, setEditorTarget] = useState("");
- useEffect(() => {
- const resolveParams = async () => {
- const resolved = await params;
- setResolvedParams(resolved);
- };
- void resolveParams();
- }, [params]);
-
- const { data: study } = api.studies.get.useQuery(
- { id: resolvedParams?.id ?? "" },
- { enabled: !!resolvedParams?.id },
- );
-
- const { data: activeConsentForm, refetch: refetchConsentForm } =
- api.studies.getActiveConsentForm.useQuery(
- { studyId: resolvedParams?.id ?? "" },
- { enabled: !!resolvedParams?.id },
- );
-
- // Only sync once when form loads to avoid resetting user edits
- useEffect(() => {
- if (activeConsentForm && !editorTarget) {
- setEditorTarget(activeConsentForm.content);
- }
- }, [activeConsentForm, editorTarget]);
-
- const editor = useEditor({
- extensions: [
- StarterKit,
- Table.configure({
- resizable: true,
- }),
- TableRow,
- TableHeader,
- TableCell,
- Markdown.configure({
- transformPastedText: true,
- }),
- ],
- content: editorTarget || '',
- immediatelyRender: false,
- onUpdate: ({ editor }) => {
- // @ts-ignore
- setEditorTarget(editor.storage.markdown.getMarkdown());
- },
- });
-
- // Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
- useEffect(() => {
- if (editor && editorTarget && editor.isEmpty) {
- editor.commands.setContent(editorTarget);
- }
- }, [editorTarget, editor]);
-
- const generateConsentMutation = api.studies.generateConsentForm.useMutation({
- onSuccess: (data) => {
- toast.success("Default Consent Form Generated!");
- setEditorTarget(data.content);
- editor?.commands.setContent(data.content);
- void refetchConsentForm();
- void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
- },
- onError: (error) => {
- toast.error("Error generating consent form", { description: error.message });
- },
- });
-
- const updateConsentMutation = api.studies.updateConsentForm.useMutation({
- onSuccess: () => {
- toast.success("Consent Form Saved Successfully!");
- void refetchConsentForm();
- void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
- },
- onError: (error) => {
- toast.error("Error saving consent form", { description: error.message });
- },
- });
-
- const handleDownloadConsent = async () => {
- if (!activeConsentForm || !study || !editor) return;
-
- try {
- toast.loading("Generating Document...", { id: "pdf-gen" });
- await downloadPdfFromHtml(editor.getHTML(), {
- filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
- });
- toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
- } catch (error) {
- toast.error("Error generating PDF", { id: "pdf-gen" });
- console.error(error);
- }
+ useEffect(() => {
+ const resolveParams = async () => {
+ const resolved = await params;
+ setResolvedParams(resolved);
};
+ void resolveParams();
+ }, [params]);
- useBreadcrumbsEffect([
- { label: "Dashboard", href: "/dashboard" },
- { label: "Studies", href: "/studies" },
- { label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
- { label: "Forms" },
- ]);
+ const { data: study } = api.studies.get.useQuery(
+ { id: resolvedParams?.id ?? "" },
+ { enabled: !!resolvedParams?.id },
+ );
- if (!session?.user) {
- return notFound();
- }
-
- if (!study) return Loading...
;
-
- return (
-
-
-
-
-
-
- {activeConsentForm && (
-
- )}
-
- }
- >
- {activeConsentForm ? (
-
-
-
-
- {activeConsentForm.title}
-
-
- v{activeConsentForm.version} • Status: Active
-
-
-
-
- Active
-
-
-
-
-
- ) : (
-
- )}
-
-
-
+ const { data: activeConsentForm, refetch: refetchConsentForm } =
+ api.studies.getActiveConsentForm.useQuery(
+ { studyId: resolvedParams?.id ?? "" },
+ { enabled: !!resolvedParams?.id },
);
+
+ // Only sync once when form loads to avoid resetting user edits
+ useEffect(() => {
+ if (activeConsentForm && !editorTarget) {
+ setEditorTarget(activeConsentForm.content);
+ }
+ }, [activeConsentForm, editorTarget]);
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Table.configure({
+ resizable: true,
+ }),
+ TableRow,
+ TableHeader,
+ TableCell,
+ Markdown.configure({
+ transformPastedText: true,
+ }),
+ ],
+ content: editorTarget || "",
+ immediatelyRender: false,
+ onUpdate: ({ editor }) => {
+ // @ts-ignore
+ setEditorTarget(editor.storage.markdown.getMarkdown());
+ },
+ });
+
+ // Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
+ useEffect(() => {
+ if (editor && editorTarget && editor.isEmpty) {
+ editor.commands.setContent(editorTarget);
+ }
+ }, [editorTarget, editor]);
+
+ const generateConsentMutation = api.studies.generateConsentForm.useMutation({
+ onSuccess: (data) => {
+ toast.success("Default Consent Form Generated!");
+ setEditorTarget(data.content);
+ editor?.commands.setContent(data.content);
+ void refetchConsentForm();
+ void utils.studies.getActivity.invalidate({
+ studyId: resolvedParams?.id ?? "",
+ });
+ },
+ onError: (error) => {
+ toast.error("Error generating consent form", {
+ description: error.message,
+ });
+ },
+ });
+
+ const updateConsentMutation = api.studies.updateConsentForm.useMutation({
+ onSuccess: () => {
+ toast.success("Consent Form Saved Successfully!");
+ void refetchConsentForm();
+ void utils.studies.getActivity.invalidate({
+ studyId: resolvedParams?.id ?? "",
+ });
+ },
+ onError: (error) => {
+ toast.error("Error saving consent form", { description: error.message });
+ },
+ });
+
+ const handleDownloadConsent = async () => {
+ if (!activeConsentForm || !study || !editor) return;
+
+ try {
+ toast.loading("Generating Document...", { id: "pdf-gen" });
+ await downloadPdfFromHtml(editor.getHTML(), {
+ filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`,
+ });
+ toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
+ } catch (error) {
+ toast.error("Error generating PDF", { id: "pdf-gen" });
+ console.error(error);
+ }
+ };
+
+ useBreadcrumbsEffect([
+ { label: "Dashboard", href: "/dashboard" },
+ { label: "Studies", href: "/studies" },
+ { label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
+ { label: "Forms" },
+ ]);
+
+ if (!session?.user) {
+ return notFound();
+ }
+
+ if (!study) return Loading...
;
+
+ return (
+
+
+
+
+
+
+ {activeConsentForm && (
+
+ )}
+
+ }
+ >
+ {activeConsentForm ? (
+
+
+
+
+ {activeConsentForm.title}
+
+
+ v{activeConsentForm.version} • Status: Active
+
+
+
+
+
+ Active
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
}
diff --git a/src/app/(dashboard)/studies/[id]/page.tsx b/src/app/(dashboard)/studies/[id]/page.tsx
index aebac74..074dd81 100755
--- a/src/app/(dashboard)/studies/[id]/page.tsx
+++ b/src/app/(dashboard)/studies/[id]/page.tsx
@@ -273,12 +273,13 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
{experiment.status}
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index 706ce09..c433574 100755
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -9,7 +9,12 @@ import {
} from "~/lib/storage/minio";
import { auth } from "~/server/auth";
import { db } from "~/server/db";
-import { experiments, mediaCaptures, studyMembers, trials } from "~/server/db/schema";
+import {
+ experiments,
+ mediaCaptures,
+ studyMembers,
+ trials,
+} from "~/server/db/schema";
const uploadSchema = z.object({
trialId: z.string().optional(),
@@ -91,15 +96,15 @@ export async function POST(request: NextRequest) {
.where(
and(
eq(studyMembers.studyId, trial[0].studyId),
- eq(studyMembers.userId, session.user.id)
- )
+ eq(studyMembers.userId, session.user.id),
+ ),
)
.limit(1);
if (!membership.length) {
return NextResponse.json(
{ error: "Insufficient permissions to upload to this trial" },
- { status: 403 }
+ { status: 403 },
);
}
}
diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx
index 9a3b82c..210e675 100755
--- a/src/components/dashboard/app-sidebar.tsx
+++ b/src/components/dashboard/app-sidebar.tsx
@@ -197,9 +197,9 @@ export function AppSidebar({
// Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({
- ...item,
- url: `/studies/${selectedStudyId}${item.url}`,
- }))
+ ...item,
+ url: `/studies/${selectedStudyId}${item.url}`,
+ }))
: [];
const handleSignOut = async () => {
diff --git a/src/components/participants/DigitalSignatureModal.tsx b/src/components/participants/DigitalSignatureModal.tsx
index 05d58ec..2cb6eb4 100644
--- a/src/components/participants/DigitalSignatureModal.tsx
+++ b/src/components/participants/DigitalSignatureModal.tsx
@@ -4,12 +4,12 @@ import { useRef, useState } from "react";
import SignatureCanvas from "react-signature-canvas";
import { Button } from "~/components/ui/button";
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
} from "~/components/ui/dialog";
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
import { api } from "~/trpc/react";
@@ -25,211 +25,250 @@ import TableHeader from "@tiptap/extension-table-header";
import { ScrollArea } from "~/components/ui/scroll-area";
interface DigitalSignatureModalProps {
- studyId: string;
- participantId: string;
- participantName?: string | null;
- participantCode: string;
- activeForm: { id: string; content: string; version: number };
- onSuccess: () => void;
+ studyId: string;
+ participantId: string;
+ participantName?: string | null;
+ participantCode: string;
+ activeForm: { id: string; content: string; version: number };
+ onSuccess: () => void;
}
export function DigitalSignatureModal({
- studyId,
- participantId,
- participantName,
- participantCode,
- activeForm,
- onSuccess,
+ studyId,
+ participantId,
+ participantName,
+ participantCode,
+ activeForm,
+ onSuccess,
}: DigitalSignatureModalProps) {
- const [isOpen, setIsOpen] = useState(false);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const sigCanvas = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const sigCanvas = useRef(null);
- // Mutations
- const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
- const recordConsentMutation = api.participants.recordConsent.useMutation();
+ // Mutations
+ const getUploadUrlMutation =
+ api.participants.getConsentUploadUrl.useMutation();
+ const recordConsentMutation = api.participants.recordConsent.useMutation();
- // Create a preview version of the text
- let previewMd = activeForm.content;
- previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
- previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
- const today = new Date().toLocaleDateString();
- previewMd = previewMd.replace(/{{DATE}}/g, today);
- previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
+ // Create a preview version of the text
+ let previewMd = activeForm.content;
+ previewMd = previewMd.replace(
+ /{{PARTICIPANT_NAME}}/g,
+ participantName ?? "_________________",
+ );
+ previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
+ const today = new Date().toLocaleDateString();
+ previewMd = previewMd.replace(/{{DATE}}/g, today);
+ previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
- const previewEditor = useEditor({
- extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
- content: previewMd,
- editable: false,
- immediatelyRender: false,
- });
+ const previewEditor = useEditor({
+ extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
+ content: previewMd,
+ editable: false,
+ immediatelyRender: false,
+ });
- const handleClear = () => {
- sigCanvas.current?.clear();
- };
+ const handleClear = () => {
+ sigCanvas.current?.clear();
+ };
- const handleSubmit = async () => {
- if (sigCanvas.current?.isEmpty()) {
- toast.error("Signature required", { description: "Please sign the document before submitting." });
- return;
- }
+ const handleSubmit = async () => {
+ if (sigCanvas.current?.isEmpty()) {
+ toast.error("Signature required", {
+ description: "Please sign the document before submitting.",
+ });
+ return;
+ }
- try {
- setIsSubmitting(true);
- toast.loading("Generating Signed Document...", { id: "sig-upload" });
+ try {
+ setIsSubmitting(true);
+ toast.loading("Generating Signed Document...", { id: "sig-upload" });
- // 1. Get Signature Image Data URL
- const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
+ // 1. Get Signature Image Data URL
+ const signatureDataUrl = sigCanvas.current
+ .getTrimmedCanvas()
+ .toDataURL("image/png");
- // 2. Prepare final Markdown and HTML
- let finalMd = activeForm.content;
- finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
- finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
- finalMd = finalMd.replace(/{{DATE}}/g, today);
- finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `
`);
+ // 2. Prepare final Markdown and HTML
+ let finalMd = activeForm.content;
+ finalMd = finalMd.replace(
+ /{{PARTICIPANT_NAME}}/g,
+ participantName ?? "_________________",
+ );
+ finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
+ finalMd = finalMd.replace(/{{DATE}}/g, today);
+ finalMd = finalMd.replace(
+ /{{SIGNATURE_IMAGE}}/g,
+ `
`,
+ );
- const headlessEditor = new Editor({
- extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
- content: finalMd,
- });
- const htmlContent = headlessEditor.getHTML();
- headlessEditor.destroy();
+ const headlessEditor = new Editor({
+ extensions: [
+ StarterKit,
+ Table,
+ TableRow,
+ TableHeader,
+ TableCell,
+ Markdown,
+ ],
+ content: finalMd,
+ });
+ const htmlContent = headlessEditor.getHTML();
+ headlessEditor.destroy();
- // 3. Generate PDF Blob
- const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
- const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
- const file = new File([pdfBlob], filename, { type: "application/pdf" });
+ // 3. Generate PDF Blob
+ const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
+ const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
+ const file = new File([pdfBlob], filename, { type: "application/pdf" });
- // 4. Get Presigned URL
- toast.loading("Uploading Document...", { id: "sig-upload" });
- const { url, key } = await getUploadUrlMutation.mutateAsync({
- studyId,
- participantId,
- filename: file.name,
- contentType: file.type,
- size: file.size,
- });
+ // 4. Get Presigned URL
+ toast.loading("Uploading Document...", { id: "sig-upload" });
+ const { url, key } = await getUploadUrlMutation.mutateAsync({
+ studyId,
+ participantId,
+ filename: file.name,
+ contentType: file.type,
+ size: file.size,
+ });
- // 5. Upload to MinIO
- await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.open("PUT", url, true);
- xhr.setRequestHeader("Content-Type", file.type);
- xhr.onload = () => {
- if (xhr.status >= 200 && xhr.status < 300) resolve();
- else reject(new Error(`Upload failed with status ${xhr.status}`));
- };
- xhr.onerror = () => reject(new Error("Network error during upload"));
- xhr.send(file);
- });
+ // 5. Upload to MinIO
+ await new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("PUT", url, true);
+ xhr.setRequestHeader("Content-Type", file.type);
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) resolve();
+ else reject(new Error(`Upload failed with status ${xhr.status}`));
+ };
+ xhr.onerror = () => reject(new Error("Network error during upload"));
+ xhr.send(file);
+ });
- // 6. Record Consent in DB
- toast.loading("Finalizing Consent...", { id: "sig-upload" });
- await recordConsentMutation.mutateAsync({
- participantId,
- consentFormId: activeForm.id,
- storagePath: key,
- });
+ // 6. Record Consent in DB
+ toast.loading("Finalizing Consent...", { id: "sig-upload" });
+ await recordConsentMutation.mutateAsync({
+ participantId,
+ consentFormId: activeForm.id,
+ storagePath: key,
+ });
- toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
- setIsOpen(false);
- onSuccess();
- } catch (error) {
- console.error(error);
- toast.error("Failed to submit digital signature", {
- id: "sig-upload",
- description: error instanceof Error ? error.message : "Unknown error",
- });
- } finally {
- setIsSubmitting(false);
- }
- };
+ toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
+ setIsOpen(false);
+ onSuccess();
+ } catch (error) {
+ console.error(error);
+ toast.error("Failed to submit digital signature", {
+ id: "sig-upload",
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
- return (
-