feat(forms): add public form access and response submission for participants

- Implemented public access to forms with `getPublic` procedure.
- Added `submitPublic` procedure for participants to submit responses.
- Created a new participant form page to handle form display and submission.
- Enhanced form validation and error handling for required fields.
- Introduced CSV export functionality for form responses.
- Updated form listing and template creation procedures.
- Added README for homepage screenshots.
This commit is contained in:
Sean O'Connor
2026-03-23 11:07:02 -04:00
parent 3270e3f8fe
commit 3959cf23f7
6 changed files with 1591 additions and 313 deletions

View File

@@ -0,0 +1,28 @@
# Homepage Screenshots
Add your app screenshots here. The homepage will display them automatically.
## Required Screenshots
1. **experiment-designer.png** - Visual experiment designer showing block-based workflow
2. **wizard-interface.png** - Wizard execution interface with trial controls
3. **dashboard.png** - Study dashboard showing experiments and trials
## Recommended Size
- Width: 1200px
- Format: PNG or WebP
- Quality: High (screenshot at 2x for retina displays)
## Preview in Browser
After adding screenshots, uncomment the `<Image>` component in `src/app/page.tsx`:
```tsx
<Image
src={screenshot.src}
alt={screenshot.alt}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
```

View File

@@ -19,6 +19,11 @@ import {
Edit2, Edit2,
Users, Users,
CheckCircle, CheckCircle,
Printer,
Download,
Pencil,
X,
FileDown,
} from "lucide-react"; } from "lucide-react";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -81,8 +86,16 @@ export default function FormViewPage({ params }: FormViewPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string; formId: string } | null>(null); const [resolvedParams, setResolvedParams] = useState<{
id: string;
formId: string;
} | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isEnteringData, setIsEnteringData] = useState(false);
const [selectedParticipantId, setSelectedParticipantId] =
useState<string>("");
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -96,6 +109,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
void resolveParams(); void resolveParams();
}, [params]); }, [params]);
const { data: participants } = api.participants.list.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id && isEnteringData },
);
const { data: study } = api.studies.get.useQuery( const { data: study } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" }, { id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id }, { enabled: !!resolvedParams?.id },
@@ -125,6 +143,143 @@ export default function FormViewPage({ params }: FormViewPageProps) {
}, },
}); });
const submitResponse = api.forms.submitResponse.useMutation({
onSuccess: () => {
toast.success("Response submitted successfully!");
setIsEnteringData(false);
setSelectedParticipantId("");
setFormResponses({});
void utils.forms.getResponses.invalidate({
formId: resolvedParams?.formId,
});
},
onError: (error) => {
toast.error("Failed to submit response", { description: error.message });
},
});
const exportCsv = api.forms.exportCsv.useQuery(
{ formId: resolvedParams?.formId ?? "" },
{ enabled: !!resolvedParams?.formId && canManage },
);
const handleExportCsv = () => {
if (exportCsv.data) {
const blob = new Blob([exportCsv.data.csv], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = exportCsv.data.filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success("CSV exported successfully!");
}
};
const generatePdf = async () => {
if (!study || !form) return;
setIsGeneratingPdf(true);
const { downloadPdfFromHtml } = await import("~/lib/pdf-generator");
const fieldsHtml = fields
.map((field, index) => {
const requiredMark = field.required
? '<span style="color: red">*</span>'
: "";
let inputField = "";
switch (field.type) {
case "text":
inputField =
'<input type="text" style="width: 100%; padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder="________________________" />';
break;
case "textarea":
inputField =
'<textarea style="width: 100%; height: 80px; padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder=""></textarea>';
break;
case "multiple_choice":
inputField = `<div style="margin-top: 4px;">${field.options
?.map((opt) => `<div><input type="checkbox" /> ${opt}</div>`)
.join("")}</div>`;
break;
case "checkbox":
inputField =
'<div style="margin-top: 4px;"><input type="checkbox" /> Yes</div>';
break;
case "yes_no":
inputField =
'<div style="margin-top: 4px;"><input type="radio" name="yn" /> Yes &nbsp; <input type="radio" name="yn" /> No</div>';
break;
case "rating":
const scale = field.settings?.scale || 5;
inputField = `<div style="margin-top: 4px;">${Array.from(
{ length: scale },
(_, i) => `<input type="radio" name="rating" /> ${i + 1} `,
).join("")}</div>`;
break;
case "date":
inputField =
'<input type="text" style="padding: 8px; border: 1px solid #ccc; margin-top: 4px;" placeholder="MM/DD/YYYY" />';
break;
case "signature":
inputField =
'<div style="height: 60px; border: 1px solid #ccc; margin-top: 4px;"></div><div style="font-size: 12px; color: #666; margin-top: 4px;">Signature: _________________________ Date: ____________</div>';
break;
}
return `
<div style="margin-bottom: 16px;">
<p style="margin: 0; font-weight: 500;">${index + 1}. ${field.label} ${requiredMark}</p>
${inputField}
</div>
`;
})
.join(
"<hr style='border: none; border-top: 1px solid #eee; margin: 16px 0;' />",
);
const html = `
<div style="max-width: 800px; margin: 0 auto; padding: 20px;">
<h1 style="margin-bottom: 8px;">${title}</h1>
${description ? `<p style="color: #666; margin-bottom: 24px;">${description}</p>` : ""}
<p style="color: #666; font-size: 12px; margin-bottom: 24px;">
<strong>Study:</strong> ${study?.name || ""} &nbsp;|&nbsp;
<strong>Form Type:</strong> ${form?.type} &nbsp;|&nbsp;
<strong>Version:</strong> ${form?.version}
</p>
<hr style="border: none; border-top: 2px solid #333; margin-bottom: 24px;" />
${fieldsHtml}
<hr style="border: none; border-top: 2px solid #333; margin-top: 24px;" />
<p style="font-size: 10px; color: #999; margin-top: 24px;">
Generated by HRIStudio | ${new Date().toLocaleDateString()}
</p>
</div>
`;
await downloadPdfFromHtml(html, {
filename: `${title.replace(/\s+/g, "_")}_form.pdf`,
});
setIsGeneratingPdf(false);
};
const handleDataEntry = () => {
if (!selectedParticipantId || !form) {
toast.error("Please select a participant");
return;
}
const answers: Record<string, any> = {};
fields.forEach((field) => {
answers[field.id] = formResponses[field.id] ?? "";
});
submitResponse.mutate({
formId: form.id,
participantId: selectedParticipantId,
responses: answers,
});
};
useEffect(() => { useEffect(() => {
if (form) { if (form) {
setTitle(form.title); setTitle(form.title);
@@ -147,26 +302,28 @@ export default function FormViewPage({ params }: FormViewPageProps) {
if (isLoading || !form) return <div>Loading...</div>; if (isLoading || !form) return <div>Loading...</div>;
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText; const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const responses = responsesData?.responses ?? []; const responses = responsesData?.responses ?? [];
const addField = (type: string) => { const addField = (type: string) => {
const newField: Field = { const newField: Field = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
type, type,
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`, label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
required: false, required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined, options:
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
}; };
setFields([...fields, newField]); setFields([...fields, newField]);
}; };
const removeField = (id: string) => { const removeField = (id: string) => {
setFields(fields.filter(f => f.id !== id)); setFields(fields.filter((f) => f.id !== id));
}; };
const updateField = (id: string, updates: Partial<Field>) => { const updateField = (id: string, updates: Partial<Field>) => {
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f)); setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
}; };
const handleSave = () => { const handleSave = () => {
@@ -191,10 +348,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</Button> </Button>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5 text-muted-foreground" /> <TypeIcon className="text-muted-foreground h-5 w-5" />
<h1 className="text-2xl font-bold">{form.title}</h1> <h1 className="text-2xl font-bold">{form.title}</h1>
{form.active && ( {form.active && (
<Badge variant="default" className="text-xs">Active</Badge> <Badge variant="default" className="text-xs">
Active
</Badge>
)} )}
</div> </div>
<p className="text-muted-foreground text-sm capitalize"> <p className="text-muted-foreground text-sm capitalize">
@@ -215,10 +374,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</Button> </Button>
</> </>
) : ( ) : (
<Button onClick={() => setIsEditing(true)}> <>
<Edit2 className="mr-2 h-4 w-4" /> <Button
Edit Form variant="outline"
</Button> onClick={generatePdf}
disabled={isGeneratingPdf}
>
<Printer className="mr-2 h-4 w-4" />
{isGeneratingPdf ? "Generating..." : "Print PDF"}
</Button>
<Button onClick={() => setIsEditing(true)}>
<Edit2 className="mr-2 h-4 w-4" />
Edit Form
</Button>
</>
)} )}
</div> </div>
)} )}
@@ -228,6 +397,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<TabsList> <TabsList>
<TabsTrigger value="fields">Fields</TabsTrigger> <TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger> <TabsTrigger value="preview">Preview</TabsTrigger>
{canManage && (
<TabsTrigger value="data-entry">Data Entry</TabsTrigger>
)}
<TabsTrigger value="responses"> <TabsTrigger value="responses">
Responses ({responses.length}) Responses ({responses.length})
</TabsTrigger> </TabsTrigger>
@@ -254,7 +426,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{fields.length === 0 ? ( {fields.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground"> <div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-2 h-8 w-8" /> <FileText className="mb-2 h-8 w-8" />
<p>No fields added yet</p> <p>No fields added yet</p>
</div> </div>
@@ -265,18 +437,26 @@ export default function FormViewPage({ params }: FormViewPageProps) {
key={field.id} key={field.id}
className="flex items-start gap-3 rounded-lg border p-4" className="flex items-start gap-3 rounded-lg border p-4"
> >
<div className="flex cursor-grab items-center text-muted-foreground"> <div className="text-muted-foreground flex cursor-grab items-center">
<GripVertical className="h-5 w-5" /> <GripVertical className="h-5 w-5" />
</div> </div>
<div className="flex-1 space-y-3"> <div className="flex-1 space-y-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{fieldTypes.find(f => f.value === field.type)?.icon}{" "} {
{fieldTypes.find(f => f.value === field.type)?.label} fieldTypes.find((f) => f.value === field.type)
?.icon
}{" "}
{
fieldTypes.find((f) => f.value === field.type)
?.label
}
</Badge> </Badge>
<Input <Input
value={field.label} value={field.label}
onChange={(e) => updateField(field.id, { label: e.target.value })} onChange={(e) =>
updateField(field.id, { label: e.target.value })
}
placeholder="Field label" placeholder="Field label"
className="flex-1" className="flex-1"
/> />
@@ -284,7 +464,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<input <input
type="checkbox" type="checkbox"
checked={field.required} checked={field.required}
onChange={(e) => updateField(field.id, { required: e.target.checked })} onChange={(e) =>
updateField(field.id, {
required: e.target.checked,
})
}
className="rounded border-gray-300" className="rounded border-gray-300"
/> />
Required Required
@@ -294,13 +478,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">Options</Label> <Label className="text-xs">Options</Label>
{field.options?.map((opt, i) => ( {field.options?.map((opt, i) => (
<div key={i} className="flex items-center gap-2"> <div
key={i}
className="flex items-center gap-2"
>
<Input <Input
value={opt} value={opt}
onChange={(e) => { onChange={(e) => {
const newOptions = [...(field.options || [])]; const newOptions = [
...(field.options || []),
];
newOptions[i] = e.target.value; newOptions[i] = e.target.value;
updateField(field.id, { options: newOptions }); updateField(field.id, {
options: newOptions,
});
}} }}
placeholder={`Option ${i + 1}`} placeholder={`Option ${i + 1}`}
className="flex-1" className="flex-1"
@@ -310,8 +501,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => { onClick={() => {
const newOptions = field.options?.filter((_, idx) => idx !== i); const newOptions = field.options?.filter(
updateField(field.id, { options: newOptions }); (_, idx) => idx !== i,
);
updateField(field.id, {
options: newOptions,
});
}} }}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -323,8 +518,13 @@ export default function FormViewPage({ params }: FormViewPageProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`]; const newOptions = [
updateField(field.id, { options: newOptions }); ...(field.options || []),
`Option ${(field.options?.length || 0) + 1}`,
];
updateField(field.id, {
options: newOptions,
});
}} }}
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
@@ -339,7 +539,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
size="icon" size="icon"
onClick={() => removeField(field.id)} onClick={() => removeField(field.id)}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="text-destructive h-4 w-4" />
</Button> </Button>
</div> </div>
))} ))}
@@ -358,16 +558,23 @@ export default function FormViewPage({ params }: FormViewPageProps) {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{fields.map((field, index) => ( {fields.map((field, index) => (
<div key={field.id} className="flex items-center gap-3 rounded-lg border p-3"> <div
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs"> key={field.id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-xs">
{index + 1} {index + 1}
</span> </span>
<div className="flex-1"> <div className="flex-1">
<p className="font-medium">{field.label}</p> <p className="font-medium">{field.label}</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
{fieldTypes.find(f => f.value === field.type)?.label} {
fieldTypes.find((f) => f.value === field.type)
?.label
}
{field.required && " • Required"} {field.required && " • Required"}
{field.type === "multiple_choice" && `${field.options?.length} options`} {field.type === "multiple_choice" &&
`${field.options?.length} options`}
</p> </p>
</div> </div>
</div> </div>
@@ -387,7 +594,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl font-semibold">{title}</h2> <h2 className="text-xl font-semibold">{title}</h2>
{description && <p className="text-muted-foreground">{description}</p>} {description && (
<p className="text-muted-foreground">{description}</p>
)}
</div> </div>
{fields.length === 0 ? ( {fields.length === 0 ? (
<p className="text-muted-foreground">No fields to preview</p> <p className="text-muted-foreground">No fields to preview</p>
@@ -397,13 +606,18 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<div key={field.id} className="space-y-2"> <div key={field.id} className="space-y-2">
<Label> <Label>
{index + 1}. {field.label} {index + 1}. {field.label}
{field.required && <span className="text-destructive"> *</span>} {field.required && (
<span className="text-destructive"> *</span>
)}
</Label> </Label>
{field.type === "text" && ( {field.type === "text" && (
<Input placeholder="Enter your response..." disabled /> <Input placeholder="Enter your response..." disabled />
)} )}
{field.type === "textarea" && ( {field.type === "textarea" && (
<Textarea placeholder="Enter your response..." disabled /> <Textarea
placeholder="Enter your response..."
disabled
/>
)} )}
{field.type === "multiple_choice" && ( {field.type === "multiple_choice" && (
<div className="space-y-2"> <div className="space-y-2">
@@ -431,18 +645,24 @@ export default function FormViewPage({ params }: FormViewPageProps) {
)} )}
{field.type === "rating" && ( {field.type === "rating" && (
<div className="flex gap-2"> <div className="flex gap-2">
{Array.from({ length: field.settings?.scale || 5 }, (_, i) => ( {Array.from(
<button key={i} type="button" className="h-8 w-8 rounded border disabled" disabled> { length: field.settings?.scale || 5 },
{i + 1} (_, i) => (
</button> <button
))} key={i}
type="button"
className="disabled h-8 w-8 rounded border"
disabled
>
{i + 1}
</button>
),
)}
</div> </div>
)} )}
{field.type === "date" && ( {field.type === "date" && <Input type="date" disabled />}
<Input type="date" disabled />
)}
{field.type === "signature" && ( {field.type === "signature" && (
<div className="h-24 rounded border bg-muted/50 flex items-center justify-center text-muted-foreground"> <div className="bg-muted/50 text-muted-foreground flex h-24 items-center justify-center rounded border">
Signature pad (disabled in preview) Signature pad (disabled in preview)
</div> </div>
)} )}
@@ -454,14 +674,263 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</Card> </Card>
</TabsContent> </TabsContent>
<TabsContent value="responses"> <TabsContent value="data-entry">
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Manual Data Entry</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => {
setIsEnteringData(!isEnteringData);
setSelectedParticipantId("");
setFormResponses({});
}}
>
{isEnteringData ? (
<>
<X className="mr-2 h-4 w-4" />
Cancel
</>
) : (
<>
<Pencil className="mr-2 h-4 w-4" />
Enter Data
</>
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isEnteringData ? (
<>
<div className="space-y-2">
<Label>Select Participant</Label>
<Select
value={selectedParticipantId}
onValueChange={setSelectedParticipantId}
>
<SelectTrigger>
<SelectValue placeholder="Choose a participant..." />
</SelectTrigger>
<SelectContent>
{participants?.participants?.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name || p.participantCode || p.email || p.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedParticipantId && (
<div className="space-y-6 border-t pt-4">
<h3 className="font-semibold">Form Responses</h3>
{fields.map((field, index) => (
<div key={field.id} className="space-y-2">
<Label>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
{field.type === "text" && (
<Input
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
placeholder="Enter response..."
/>
)}
{field.type === "textarea" && (
<Textarea
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
placeholder="Enter response..."
/>
)}
{field.type === "multiple_choice" && (
<Select
value={formResponses[field.id] || ""}
onValueChange={(val) =>
setFormResponses({
...formResponses,
[field.id]: val,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select an option..." />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt, i) => (
<SelectItem key={i} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{field.type === "checkbox" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formResponses[field.id] || false}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.checked,
})
}
className="h-4 w-4"
/>
<span>Yes</span>
</div>
)}
{field.type === "yes_no" && (
<Select
value={formResponses[field.id] || ""}
onValueChange={(val) =>
setFormResponses({
...formResponses,
[field.id]: val,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
)}
{field.type === "rating" && (
<Select
value={String(formResponses[field.id] || "")}
onValueChange={(val) =>
setFormResponses({
...formResponses,
[field.id]: parseInt(val),
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select rating..." />
</SelectTrigger>
<SelectContent>
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<SelectItem key={i} value={String(i + 1)}>
{i + 1}
</SelectItem>
),
)}
</SelectContent>
</Select>
)}
{field.type === "date" && (
<Input
type="date"
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
/>
)}
{field.type === "signature" && (
<div className="space-y-2">
<Input
value={formResponses[field.id] || ""}
onChange={(e) =>
setFormResponses({
...formResponses,
[field.id]: e.target.value,
})
}
placeholder="Type name as signature..."
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that
the information provided is accurate.
</p>
</div>
)}
</div>
))}
<div className="flex justify-end gap-2 border-t pt-4">
<Button
variant="outline"
onClick={() => {
setIsEnteringData(false);
setSelectedParticipantId("");
setFormResponses({});
}}
>
Cancel
</Button>
<Button
onClick={handleDataEntry}
disabled={submitResponse.isPending}
>
<Save className="mr-2 h-4 w-4" />
{submitResponse.isPending
? "Saving..."
: "Save Response"}
</Button>
</div>
</div>
)}
</>
) : (
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<Pencil className="mb-2 h-8 w-8" />
<p>Manual data entry</p>
<p className="text-sm">
Enter responses directly for participants who completed the
form on paper
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="responses">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Form Responses</CardTitle> <CardTitle>Form Responses</CardTitle>
{canManage && responses.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleExportCsv}
disabled={exportCsv.isFetching}
>
<FileDown className="mr-2 h-4 w-4" />
{exportCsv.isFetching ? "Exporting..." : "Export CSV"}
</Button>
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{responses.length === 0 ? ( {responses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground"> <div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
<Users className="mb-2 h-8 w-8" /> <Users className="mb-2 h-8 w-8" />
<p>No responses yet</p> <p>No responses yet</p>
</div> </div>
@@ -469,27 +938,35 @@ export default function FormViewPage({ params }: FormViewPageProps) {
<div className="space-y-4"> <div className="space-y-4">
{responses.map((response) => ( {responses.map((response) => (
<div key={response.id} className="rounded-lg border p-4"> <div key={response.id} className="rounded-lg border p-4">
<div className="flex items-center justify-between mb-3"> <div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="text-muted-foreground h-4 w-4" />
<span className="font-medium"> <span className="font-medium">
{response.participant?.name || response.participant?.participantCode || "Unknown"} {response.participant?.name ||
response.participant?.participantCode ||
"Unknown"}
</span> </span>
</div> </div>
<Badge className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}> <Badge
className={`text-xs ${statusColors[response.status as keyof typeof statusColors]}`}
>
{response.status} {response.status}
</Badge> </Badge>
</div> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
{Object.entries(response.responses as Record<string, any>).map(([key, value]) => ( {Object.entries(
response.responses as Record<string, any>,
).map(([key, value]) => (
<div key={key} className="flex gap-2"> <div key={key} className="flex gap-2">
<span className="text-muted-foreground">{key}:</span> <span className="text-muted-foreground">
{key}:
</span>
<span>{String(value)}</span> <span>{String(value)}</span>
</div> </div>
))} ))}
</div> </div>
{response.signedAt && ( {response.signedAt && (
<div className="mt-2 pt-2 border-t text-xs text-muted-foreground"> <div className="text-muted-foreground mt-2 border-t pt-2 text-xs">
Signed: {new Date(response.signedAt).toLocaleString()} Signed: {new Date(response.signedAt).toLocaleString()}
</div> </div>
)} )}
@@ -503,4 +980,4 @@ export default function FormViewPage({ params }: FormViewPageProps) {
</Tabs> </Tabs>
</div> </div>
); );
} }

View File

@@ -13,7 +13,6 @@ import {
FileQuestion, FileQuestion,
FileSignature, FileSignature,
MoreHorizontal, MoreHorizontal,
Pencil,
Trash2, Trash2,
Eye, Eye,
CheckCircle, CheckCircle,
@@ -40,9 +39,11 @@ const formTypeIcons = {
}; };
const formTypeColors = { const formTypeColors = {
consent: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400", consent:
"bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400",
survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400", survey: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
questionnaire: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400", questionnaire:
"bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
}; };
interface StudyFormsPageProps { interface StudyFormsPageProps {
@@ -55,7 +56,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const utils = api.useUtils(); const utils = api.useUtils();
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null); const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
useEffect(() => { useEffect(() => {
@@ -134,10 +137,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
{forms.length === 0 && !isLoading ? ( {forms.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground mb-4" /> <FileText className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="text-lg font-semibold mb-2">No Forms Yet</h3> <h3 className="mb-2 text-lg font-semibold">No Forms Yet</h3>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Create consent forms, surveys, or questionnaires to collect data from participants Create consent forms, surveys, or questionnaires to collect data
from participants
</p> </p>
{canManage && ( {canManage && (
<Button asChild> <Button asChild>
@@ -151,8 +155,8 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm"> <div className="relative max-w-sm flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="Search forms..." placeholder="Search forms..."
value={search} value={search}
@@ -164,8 +168,12 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{forms.map((form) => { {forms.map((form) => {
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText; const TypeIcon =
const typeColor = formTypeColors[form.type as keyof typeof formTypeColors] || "bg-gray-100"; formTypeIcons[form.type as keyof typeof formTypeIcons] ||
FileText;
const typeColor =
formTypeColors[form.type as keyof typeof formTypeColors] ||
"bg-gray-100";
const isActive = form.active; const isActive = form.active;
return ( return (
@@ -177,7 +185,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
<TypeIcon className="h-4 w-4" /> <TypeIcon className="h-4 w-4" />
</div> </div>
<div> <div>
<CardTitle className="text-base">{form.title}</CardTitle> <CardTitle className="text-base">
{form.title}
</CardTitle>
<p className="text-muted-foreground text-xs capitalize"> <p className="text-muted-foreground text-xs capitalize">
{form.type} {form.type}
</p> </p>
@@ -192,18 +202,22 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
</CardHeader> </CardHeader>
<CardContent className="pb-3"> <CardContent className="pb-3">
{form.description && ( {form.description && (
<p className="text-muted-foreground text-sm line-clamp-2 mb-3"> <p className="text-muted-foreground mb-3 line-clamp-2 text-sm">
{form.description} {form.description}
</p> </p>
)} )}
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center justify-between text-xs">
<span>v{form.version}</span> <span>v{form.version}</span>
<span>{(form as any)._count?.responses ?? 0} responses</span> <span>
{(form as any)._count?.responses ?? 0} responses
</span>
</div> </div>
</CardContent> </CardContent>
<div className="flex items-center justify-between border-t bg-muted/30 px-4 py-2"> <div className="bg-muted/30 flex items-center justify-between border-t px-4 py-2">
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}`}> <Link
href={`/studies/${resolvedParams?.id}/forms/${form.id}`}
>
<Eye className="mr-1 h-3 w-3" /> <Eye className="mr-1 h-3 w-3" />
View View
</Link> </Link>
@@ -216,15 +230,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
{!isActive && ( {!isActive && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setActiveMutation.mutate({ id: form.id })} onClick={() =>
setActiveMutation.mutate({ id: form.id })
}
> >
<CheckCircle className="mr-2 h-4 w-4" /> <CheckCircle className="mr-2 h-4 w-4" />
Set Active Set Active
@@ -232,7 +242,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
)} )}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (confirm("Are you sure you want to delete this form?")) { if (
confirm(
"Are you sure you want to delete this form?",
)
) {
deleteMutation.mutate({ id: form.id }); deleteMutation.mutate({ id: form.id });
} }
}} }}
@@ -253,4 +267,4 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
)} )}
</div> </div>
); );
} }

View File

@@ -0,0 +1,430 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
FileText,
FileSignature,
ClipboardList,
FileQuestion,
CheckCircle,
AlertCircle,
Loader2,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { toast } from "sonner";
interface Field {
id: string;
type: string;
label: string;
required: boolean;
options?: string[];
settings?: Record<string, any>;
}
const formTypeIcons = {
consent: FileSignature,
survey: ClipboardList,
questionnaire: FileQuestion,
};
export default function ParticipantFormPage() {
const params = useParams();
const searchParams = useSearchParams();
const formId = params.formId as string;
const [participantCode, setParticipantCode] = useState("");
const [formResponses, setFormResponses] = useState<Record<string, any>>({});
const [hasSubmitted, setHasSubmitted] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const { data: form, isLoading: formLoading } = api.forms.getPublic.useQuery(
{ id: formId },
{ enabled: !!formId },
);
const submitResponse = api.forms.submitPublic.useMutation({
onSuccess: () => {
toast.success("Response submitted successfully!");
setHasSubmitted(true);
},
onError: (error: { message: string }) => {
toast.error("Submission failed", { description: error.message });
},
});
useEffect(() => {
const code = searchParams.get("code");
if (code) {
setParticipantCode(code);
}
}, [searchParams]);
if (formLoading) {
return (
<div className="bg-background flex min-h-[60vh] items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
if (!form) {
return (
<div className="bg-background flex min-h-[60vh] flex-col items-center justify-center text-center">
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
<h1 className="text-2xl font-bold">Form Not Found</h1>
<p className="text-muted-foreground mt-2">
This form may have been removed or the link is invalid.
</p>
</div>
);
}
if (hasSubmitted) {
return (
<div className="bg-background flex min-h-[60vh] flex-col items-center justify-center text-center">
<div className="mb-4 rounded-full bg-green-100 p-4">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-green-600">Thank You!</h1>
<p className="text-muted-foreground mt-2 max-w-md">
Your response has been submitted successfully.
{form.type === "consent" && " Please proceed with your session."}
</p>
<Button variant="outline" className="mt-6" asChild>
<Link href="/">Return Home</Link>
</Button>
</div>
);
}
const TypeIcon =
formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
const fields = (form.fields as Field[]) || [];
const validateForm = (): boolean => {
const errors: Record<string, string> = {};
let isValid = true;
fields.forEach((field) => {
if (field.required) {
const value = formResponses[field.id];
if (
value === undefined ||
value === null ||
value === "" ||
(typeof value === "string" && value.trim() === "")
) {
errors[field.id] = "This field is required";
isValid = false;
}
}
});
setFieldErrors(errors);
return isValid;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!participantCode.trim()) {
toast.error("Please enter your participant code");
return;
}
if (!validateForm()) {
toast.error("Please fill in all required fields");
return;
}
submitResponse.mutate({
formId,
participantCode: participantCode.trim(),
responses: formResponses,
});
};
const updateResponse = (fieldId: string, value: any) => {
setFormResponses({ ...formResponses, [fieldId]: value });
if (fieldErrors[fieldId]) {
const newErrors = { ...fieldErrors };
delete newErrors[fieldId];
setFieldErrors(newErrors);
}
};
return (
<div className="bg-background min-h-screen py-8">
<div className="mx-auto max-w-2xl px-4">
<div className="mb-8 text-center">
<div className="bg-primary/10 mb-4 inline-flex rounded-full p-3">
<TypeIcon className="text-primary h-8 w-8" />
</div>
<h1 className="text-3xl font-bold">{form.title}</h1>
{form.description && (
<p className="text-muted-foreground mt-3 text-lg">
{form.description}
</p>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{form.type === "consent"
? "Consent Form"
: form.type === "survey"
? "Survey"
: "Questionnaire"}
</CardTitle>
<CardDescription>
Fields marked with <span className="text-destructive">*</span> are
required
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="participantCode">
Participant Code <span className="text-destructive">*</span>
</Label>
<Input
id="participantCode"
value={participantCode}
onChange={(e) => setParticipantCode(e.target.value)}
placeholder="Enter your participant code (e.g., P001)"
required
/>
<p className="text-muted-foreground text-xs">
Enter the participant code provided by the researcher
</p>
</div>
<div className="border-t pt-6">
{fields.map((field, index) => (
<div key={field.id} className="mb-6 last:mb-0">
<Label
htmlFor={field.id}
className={
fieldErrors[field.id] ? "text-destructive" : ""
}
>
{index + 1}. {field.label}
{field.required && (
<span className="text-destructive"> *</span>
)}
</Label>
<div className="mt-2">
{field.type === "text" && (
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "textarea" && (
<Textarea
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Enter your response..."
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "multiple_choice" && (
<div
className={`mt-2 space-y-2 ${fieldErrors[field.id] ? "border-destructive rounded-md border p-2" : ""}`}
>
{field.options?.map((opt, i) => (
<label
key={i}
className="flex cursor-pointer items-center gap-2"
>
<input
type="radio"
name={field.id}
value={opt}
checked={formResponses[field.id] === opt}
onChange={() => updateResponse(field.id, opt)}
className="h-4 w-4"
/>
<span className="text-sm">{opt}</span>
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={field.id}
checked={formResponses[field.id] || false}
onChange={(e) =>
updateResponse(field.id, e.target.checked)
}
className="h-4 w-4 rounded border-gray-300"
/>
<Label
htmlFor={field.id}
className="cursor-pointer font-normal"
>
Yes, I agree
</Label>
</div>
)}
{field.type === "yes_no" && (
<div className="mt-2 flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="yes"
checked={formResponses[field.id] === "yes"}
onChange={() => updateResponse(field.id, "yes")}
className="h-4 w-4"
/>
<span className="text-sm">Yes</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name={field.id}
value="no"
checked={formResponses[field.id] === "no"}
onChange={() => updateResponse(field.id, "no")}
className="h-4 w-4"
/>
<span className="text-sm">No</span>
</label>
</div>
)}
{field.type === "rating" && (
<div className="mt-2 flex flex-wrap gap-2">
{Array.from(
{ length: field.settings?.scale || 5 },
(_, i) => (
<label key={i} className="cursor-pointer">
<input
type="radio"
name={field.id}
value={String(i + 1)}
checked={formResponses[field.id] === i + 1}
onChange={() =>
updateResponse(field.id, i + 1)
}
className="peer sr-only"
/>
<span className="hover:bg-muted peer-checked:bg-primary peer-checked:text-primary-foreground flex h-10 w-10 items-center justify-center rounded-full border text-sm font-medium transition-colors">
{i + 1}
</span>
</label>
),
)}
</div>
)}
{field.type === "date" && (
<Input
type="date"
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
)}
{field.type === "signature" && (
<div className="space-y-2">
<Input
id={field.id}
value={formResponses[field.id] || ""}
onChange={(e) =>
updateResponse(field.id, e.target.value)
}
placeholder="Type your full name as signature"
className={
fieldErrors[field.id] ? "border-destructive" : ""
}
/>
<p className="text-muted-foreground text-xs">
By entering your name above, you confirm that the
information provided is accurate.
</p>
</div>
)}
</div>
{fieldErrors[field.id] && (
<p className="text-destructive mt-1 text-sm">
{fieldErrors[field.id]}
</p>
)}
</div>
))}
</div>
<div className="border-t pt-6">
<Button
type="submit"
size="lg"
className="w-full"
disabled={submitResponse.isPending}
>
{submitResponse.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Submit Response
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
<p className="text-muted-foreground mt-6 text-center text-sm">
Powered by HRIStudio
</p>
</div>
</div>
);
}

View File

@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -8,18 +9,41 @@ import { Logo } from "~/components/ui/logo";
import { auth } from "~/lib/auth"; import { auth } from "~/lib/auth";
import { import {
ArrowRight, ArrowRight,
Beaker,
Bot, Bot,
Database, Database,
LayoutTemplate, LayoutTemplate,
Lock, Lock,
Network, Network,
PlayCircle,
Settings2, Settings2,
Share2, Share2,
Sparkles, Sparkles,
Users,
Beaker,
FileText,
PlayCircle,
} from "lucide-react"; } from "lucide-react";
const screenshots = [
{
src: "/images/screenshots/experiment-designer.png",
alt: "Visual Experiment Designer",
label: "Design",
className: "md:col-span-2 md:row-span-2",
},
{
src: "/images/screenshots/wizard-interface.png",
alt: "Wizard Execution Interface",
label: "Execute",
className: "",
},
{
src: "/images/screenshots/dashboard.png",
alt: "Study Dashboard",
label: "Dashboard",
className: "",
},
];
export default async function Home() { export default async function Home() {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
@@ -40,7 +64,7 @@ export default async function Home() {
<Link href="#features">Features</Link> <Link href="#features">Features</Link>
</Button> </Button>
<Button variant="ghost" asChild className="hidden sm:inline-flex"> <Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="#architecture">Architecture</Link> <Link href="#how-it-works">How It Works</Link>
</Button> </Button>
<div className="bg-border hidden h-6 w-px sm:block" /> <div className="bg-border hidden h-6 w-px sm:block" />
<Button variant="ghost" asChild> <Button variant="ghost" asChild>
@@ -55,8 +79,7 @@ export default async function Home() {
<main className="flex-1"> <main className="flex-1">
{/* Hero Section */} {/* Hero Section */}
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32"> <section className="relative overflow-hidden pt-20 pb-24 md:pt-32">
{/* Background Gradients */}
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" /> <div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
<div className="container mx-auto flex flex-col items-center px-4 text-center"> <div className="container mx-auto flex flex-col items-center px-4 text-center">
@@ -65,26 +88,27 @@ export default async function Home() {
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium" className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
> >
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" /> <Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
The Modern Standard for HRI Research Open Source WoZ Platform
</Badge> </Badge>
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl"> <h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
Reproducible WoZ Studies <br className="hidden md:block" /> Wizard-of-Oz Studies <br className="hidden md:block" />
<span className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-violet-400"> <span className="bg-gradient-to-r from-cyan-500 to-blue-600 bg-clip-text text-transparent">
Made Simple Made Scientific
</span> </span>
</h1> </h1>
<p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl"> <p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
HRIStudio is the open-source platform that bridges the gap between HRIStudio is the open-source platform that makes human-robot
ease of use and scientific rigor. Design, execute, and analyze interaction research reproducible, accessible, and collaborative.
human-robot interaction experiments with zero friction. Design experiments, control robots, and analyze results all in
one place.
</p> </p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center"> <div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center">
<Button size="lg" className="h-12 px-8 text-base" asChild> <Button size="lg" className="h-12 px-8 text-base" asChild>
<Link href="/auth/signup"> <Link href="/auth/signup">
Start Researching Start Your Research
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
</Link> </Link>
</Button> </Button>
@@ -102,127 +126,160 @@ export default async function Home() {
</Link> </Link>
</Button> </Button>
</div> </div>
</div>
</section>
{/* Mockup / Visual Interest */} {/* Screenshots Section */}
<div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4"> <section id="screenshots" className="container mx-auto px-4 py-12">
<div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" /> <div className="grid gap-4 md:grid-cols-3">
<div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border"> {screenshots.map((screenshot, index) => (
{/* Placeholder for actual app screenshot */} <div
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" /> key={index}
<div className="p-8 text-center"> className={`group bg-muted/50 relative overflow-hidden rounded-xl border ${screenshot.className}`}
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" /> >
<p className="text-muted-foreground font-medium"> {/* Placeholder - replace src with actual screenshot */}
Interactive Experiment Designer <div className="from-muted to-muted/50 absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-br">
<div className="bg-background/80 mb-4 rounded-lg px-4 py-2 shadow-sm">
<span className="text-muted-foreground text-xs font-medium tracking-wider uppercase">
{screenshot.label}
</span>
</div>
<FileText className="text-muted-foreground/30 h-16 w-16" />
<p className="text-muted-foreground/50 mt-4 text-sm">
Screenshot: {screenshot.alt}
</p>
<p className="text-muted-foreground/30 mt-1 text-xs">
Replace with actual image
</p> </p>
</div> </div>
{/* Uncomment when you have real screenshots:
<Image
src={screenshot.src}
alt={screenshot.alt}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
*/}
</div> </div>
))}
</div>
<p className="text-muted-foreground mt-4 text-center text-sm">
Add screenshots to{" "}
<code className="bg-muted rounded px-2 py-1">
public/images/screenshots/
</code>
</p>
</section>
{/* Features Section */}
<section id="features" className="bg-muted/30 border-t py-24">
<div className="container mx-auto px-4">
<div className="mb-16 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
Built for Scientific Rigor
</h2>
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
Everything you need to conduct reproducible Wizard-of-Oz
studies, from experiment design to data analysis.
</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={LayoutTemplate}
title="Visual Experiment Designer"
description="Build complex branching narratives with drag-and-drop blocks. No coding required — just drag, configure, and run."
color="blue"
/>
<FeatureCard
icon={PlayCircle}
title="Guided Wizard Interface"
description="Step-by-step protocol execution keeps wizards on track. Every action is logged with timestamps."
color="violet"
/>
<FeatureCard
icon={Bot}
title="Robot Agnostic"
description="Design experiments once, run on any robot. NAO, Pepper, TurtleBot — your logic stays the same."
color="green"
/>
<FeatureCard
icon={Users}
title="Role-Based Collaboration"
description="Invite PIs, wizards, and observers. Each role sees exactly what they need — nothing more."
color="orange"
/>
<FeatureCard
icon={Database}
title="Automatic Data Logging"
description="Every action, timestamp, and sensor reading is captured. Export to CSV for analysis."
color="rose"
/>
<FeatureCard
icon={Lock}
title="Built-in Reproducibility"
description="Protocol/trial separation, deviation logging, and comprehensive audit trails make replication trivial."
color="cyan"
/>
</div> </div>
</div> </div>
</section> </section>
{/* Features Bento Grid */} {/* How It Works */}
<section id="features" className="container mx-auto px-4 py-24"> <section id="how-it-works" className="container mx-auto px-4 py-24">
<div className="mb-12 text-center"> <div className="mb-16 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl"> <h2 className="text-3xl font-bold tracking-tight md:text-4xl">
Everything You Need How It Works
</h2> </h2>
<p className="text-muted-foreground mt-4 text-lg"> <p className="text-muted-foreground mt-4 text-lg">
Built for the specific needs of HRI researchers and wizards. From design to publication in one unified workflow.
</p> </p>
</div> </div>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2"> <div className="relative">
{/* Visual Designer - Large Item */} {/* Connection line */}
<Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 dark:from-blue-900/10 dark:to-violet-900/10"> <div className="bg-border absolute top-0 left-1/2 hidden h-full w-px -translate-x-1/2 lg:block" />
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5 text-blue-500" />
Visual Experiment Designer
</CardTitle>
</CardHeader>
<CardContent className="flex-1">
<p className="text-muted-foreground mb-6">
Construct complex branching narratives without writing a
single line of code. Our node-based editor handles logic,
timing, and robot actions automatically.
</p>
<div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<span className="bg-accent rounded p-2">Start</span>
<ArrowRight className="h-4 w-4" />
<span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
Robot: Greet
</span>
<ArrowRight className="h-4 w-4" />
<span className="bg-accent rounded p-2">Wait: 5s</span>
</div>
</div>
</CardContent>
</Card>
{/* Robot Agnostic */} <div className="space-y-12 lg:space-y-0">
<Card className="col-span-1 md:col-span-1 lg:col-span-2"> <WorkflowStep
<CardHeader> number={1}
<CardTitle className="flex items-center gap-2"> title="Design"
<Bot className="h-5 w-5 text-green-500" /> description="Use the visual editor to build your experiment protocol with drag-and-drop blocks. Add speech, gestures, conditions, and branching logic — no code required."
Robot Agnostic icon={LayoutTemplate}
</CardTitle> />
</CardHeader> <WorkflowStep
<CardContent> number={2}
<p className="text-muted-foreground"> title="Configure"
Switch between robots instantly. Whether it's a NAO, Pepper, description="Set up your study, invite team members with appropriate roles, and configure your robot platform."
or a custom ROS2 bot, your experiment logic remains strictly icon={Settings2}
separated from hardware implementation. />
</p> <WorkflowStep
</CardContent> number={3}
</Card> title="Execute"
description="Run trials with the wizard interface. Real-time updates keep everyone in sync. Every action is automatically logged."
{/* Role Based */} icon={PlayCircle}
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1"> />
<CardHeader> <WorkflowStep
<CardTitle className="flex items-center gap-2 text-base"> number={4}
<Lock className="h-4 w-4 text-orange-500" /> title="Analyze"
Role-Based Access description="Review trial data, export responses, and compare across participants. Everything is timestamped and synchronized."
</CardTitle> icon={Share2}
</CardHeader> />
<CardContent> </div>
<p className="text-muted-foreground text-sm">
Granular permissions for Principal Investigators, Wizards, and
Observers.
</p>
</CardContent>
</Card>
{/* Data Logging */}
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Database className="h-4 w-4 text-rose-500" />
Full Traceability
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Every wizard action, automated response, and sensor reading is
time-stamped and logged.
</p>
</CardContent>
</Card>
</div> </div>
</section> </section>
{/* Architecture Section */} {/* Architecture Section */}
<section id="architecture" className="bg-muted/30 border-t py-24"> <section id="architecture" className="bg-muted/30 border-t py-24">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8"> <div className="grid items-center gap-12 lg:grid-cols-2">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight"> <h2 className="text-3xl font-bold tracking-tight">
Enterprise-Grade Architecture Modern Architecture
</h2> </h2>
<p className="text-muted-foreground mt-4 text-lg"> <p className="text-muted-foreground mt-4 text-lg">
Designed for reliability and scale. HRIStudio uses a modern Built on proven technologies for reliability, type safety, and
stack to ensure your data is safe and your experiments run real-time collaboration.
smoothly.
</p> </p>
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
@@ -232,9 +289,9 @@ export default async function Home() {
</div> </div>
<div> <div>
<h3 className="font-semibold">3-Layer Design</h3> <h3 className="font-semibold">3-Layer Design</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-sm">
Clear separation between UI, Data, and Hardware layers UI, application logic, and hardware layers are strictly
for maximum stability. separated for stability.
</p> </p>
</div> </div>
</div> </div>
@@ -243,72 +300,74 @@ export default async function Home() {
<Share2 className="text-primary h-5 w-5" /> <Share2 className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold"> <h3 className="font-semibold">Real-Time Sync</h3>
Collaborative by Default <p className="text-muted-foreground text-sm">
</h3> WebSocket updates keep wizard and observer views
<p className="text-muted-foreground"> perfectly synchronized.
Real-time state synchronization allows multiple
researchers to monitor a single trial.
</p> </p>
</div> </div>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm"> <div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
<Settings2 className="text-primary h-5 w-5" /> <Beaker className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<h3 className="font-semibold">ROS2 Integration</h3> <h3 className="font-semibold">Plugin System</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground text-sm">
Native support for ROS2 nodes, topics, and actions right Extend with custom robot integrations and actions
out of the box. through a simple JSON configuration.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="relative mx-auto w-full max-w-[500px]"> <div className="relative space-y-4">
{/* Abstract representation of architecture */} <Card className="border-blue-500/20 bg-blue-500/5">
<div className="relative z-10 space-y-4"> <CardHeader className="pb-2">
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2"> <CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
<CardHeader className="pb-2"> APP LAYER
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400"> </CardTitle>
APP LAYER </CardHeader>
</CardTitle> <CardContent>
</CardHeader> <p className="text-sm font-medium">
<CardContent> Next.js + React + tRPC
<p className="font-semibold"> </p>
Next.js Dashboard + Experiment Designer <p className="text-muted-foreground text-xs">
</p> Type-safe full-stack
</CardContent> </p>
</Card> </CardContent>
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6"> </Card>
<CardHeader className="pb-2"> <Card className="border-violet-500/20 bg-violet-500/5">
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400"> <CardHeader className="pb-2">
DATA LAYER <CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
</CardTitle> DATA LAYER
</CardHeader> </CardTitle>
<CardContent> </CardHeader>
<p className="font-semibold"> <CardContent>
PostgreSQL + MinIO + TRPC API <p className="text-sm font-medium">
</p> PostgreSQL + MinIO + WebSocket
</CardContent> </p>
</Card> <p className="text-muted-foreground text-xs">
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10"> Persistent storage + real-time
<CardHeader className="pb-2"> </p>
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400"> </CardContent>
HARDWARE LAYER </Card>
</CardTitle> <Card className="border-green-500/20 bg-green-500/5">
</CardHeader> <CardHeader className="pb-2">
<CardContent> <CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
<p className="font-semibold"> ROBOT LAYER
ROS2 Bridge + Robot Plugins </CardTitle>
</p> </CardHeader>
</CardContent> <CardContent>
</Card> <p className="text-sm font-medium">
</div> ROS2 Bridge + Plugin Config
{/* Decorative blobs */} </p>
<div className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" /> <p className="text-muted-foreground text-xs">
Platform agnostic
</p>
</CardContent>
</Card>
</div> </div>
</div> </div>
</div> </div>
@@ -316,21 +375,33 @@ export default async function Home() {
{/* CTA Section */} {/* CTA Section */}
<section className="container mx-auto px-4 py-24 text-center"> <section className="container mx-auto px-4 py-24 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl"> <div className="mx-auto max-w-2xl">
Ready to upgrade your lab? <h2 className="text-3xl font-bold tracking-tight md:text-4xl">
</h2> Ready to upgrade your research?
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg"> </h2>
Join the community of researchers building the future of HRI with <p className="text-muted-foreground mt-4 text-lg">
reproducible, open-source tools. Join researchers building reproducible HRI studies with
</p> open-source tools.
<div className="mt-8"> </p>
<Button <div className="mt-8 flex flex-col gap-4 sm:flex-row sm:justify-center">
size="lg" <Button
className="shadow-primary/20 h-12 px-8 text-base shadow-lg" size="lg"
asChild className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
> asChild
<Link href="/auth/signup">Get Started for Free</Link> >
</Button> <Link href="/auth/signup">Get Started Free</Link>
</Button>
<Button
size="lg"
variant="outline"
className="h-12 px-8 text-base"
asChild
>
<Link href="/docs" target="_blank">
Read the Docs
</Link>
</Button>
</div>
</div> </div>
</section> </section>
</main> </main>
@@ -340,25 +411,96 @@ export default async function Home() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Logo iconSize="sm" showText={true} /> <Logo iconSize="sm" showText={true} />
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved. &copy; {new Date().getFullYear()} HRIStudio. Open source under MIT
License.
</p> </p>
</div> </div>
<div className="text-muted-foreground flex gap-6 text-sm"> <div className="text-muted-foreground flex gap-6 text-sm">
<Link href="/docs" className="hover:text-foreground">
Docs
</Link>
<Link
href="https://github.com/robolab/hristudio"
className="hover:text-foreground"
target="_blank"
>
GitHub
</Link>
<Link href="#" className="hover:text-foreground"> <Link href="#" className="hover:text-foreground">
Privacy Privacy
</Link> </Link>
<Link href="#" className="hover:text-foreground"> <Link href="#" className="hover:text-foreground">
Terms Terms
</Link> </Link>
<Link href="#" className="hover:text-foreground">
GitHub
</Link>
<Link href="#" className="hover:text-foreground">
Documentation
</Link>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
); );
} }
function FeatureCard({
icon: Icon,
title,
description,
color,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
color: "blue" | "violet" | "green" | "orange" | "rose" | "cyan";
}) {
const colors = {
blue: "text-blue-500 bg-blue-500/10",
violet: "text-violet-500 bg-violet-500/10",
green: "text-green-500 bg-green-500/10",
orange: "text-orange-500 bg-orange-500/10",
rose: "text-rose-500 bg-rose-500/10",
cyan: "text-cyan-500 bg-cyan-500/10",
};
return (
<Card>
<CardHeader>
<div
className={`mb-2 inline-flex h-10 w-10 items-center justify-center rounded-lg ${colors[color]}`}
>
<Icon className="h-5 w-5" />
</div>
<CardTitle className="text-lg">{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">{description}</p>
</CardContent>
</Card>
);
}
function WorkflowStep({
number,
title,
description,
icon: Icon,
}: {
number: number;
title: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="relative flex flex-col items-center gap-4 lg:flex-row lg:gap-8">
<div className="border-primary bg-background text-primary z-10 flex h-12 w-12 shrink-0 items-center justify-center rounded-full border-2 font-bold">
{number}
</div>
<Card className="flex-1">
<CardHeader className="flex flex-row items-center gap-4 pb-2">
<Icon className="text-primary h-5 w-5" />
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{description}</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -2,7 +2,11 @@ import { TRPCError } from "@trpc/server";
import { and, count, desc, eq, ilike, or } from "drizzle-orm"; import { and, count, desc, eq, ilike, or } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from "~/server/api/trpc";
import { import {
activityLogs, activityLogs,
formResponses, formResponses,
@@ -11,6 +15,7 @@ import {
formFieldTypeEnum, formFieldTypeEnum,
participants, participants,
studyMembers, studyMembers,
studies,
userSystemRoles, userSystemRoles,
} from "~/server/db/schema"; } from "~/server/db/schema";
@@ -60,7 +65,7 @@ async function checkStudyAccess(
export const formsRouter = createTRPCRouter({ export const formsRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z.object({
studyId: z.string().uuid(), studyId: z.string().uuid(),
type: z.enum(formTypes).optional(), type: z.enum(formTypes).optional(),
@@ -116,8 +121,11 @@ export const formsRouter = createTRPCRouter({
.select({ count: count() }) .select({ count: count() })
.from(formResponses) .from(formResponses)
.where(eq(formResponses.formId, form.id)); .where(eq(formResponses.formId, form.id));
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } }; return {
}) ...form,
_count: { responses: responseCount[0]?.count ?? 0 },
};
}),
); );
return { return {
@@ -178,16 +186,18 @@ export const formsRouter = createTRPCRouter({
type: z.enum(formTypes), type: z.enum(formTypes),
title: z.string().min(1).max(255), title: z.string().min(1).max(255),
description: z.string().optional(), description: z.string().optional(),
fields: z.array( fields: z
z.object({ .array(
id: z.string(), z.object({
type: z.string(), id: z.string(),
label: z.string(), type: z.string(),
required: z.boolean().default(false), label: z.string(),
options: z.array(z.string()).optional(), required: z.boolean().default(false),
settings: z.record(z.string(), z.any()).optional(), options: z.array(z.string()).optional(),
}), settings: z.record(z.string(), z.any()).optional(),
).default([]), }),
)
.default([]),
settings: z.record(z.string(), z.any()).optional(), settings: z.record(z.string(), z.any()).optional(),
isTemplate: z.boolean().optional(), isTemplate: z.boolean().optional(),
templateName: z.string().max(100).optional(), templateName: z.string().max(100).optional(),
@@ -195,7 +205,7 @@ export const formsRouter = createTRPCRouter({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { isTemplate, templateName, ...formData } = input; const { isTemplate, templateName, ...formData } = input;
if (isTemplate && !templateName) { if (isTemplate && !templateName) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
@@ -248,16 +258,18 @@ export const formsRouter = createTRPCRouter({
id: z.string().uuid(), id: z.string().uuid(),
title: z.string().min(1).max(255).optional(), title: z.string().min(1).max(255).optional(),
description: z.string().optional(), description: z.string().optional(),
fields: z.array( fields: z
z.object({ .array(
id: z.string(), z.object({
type: z.string(), id: z.string(),
label: z.string(), type: z.string(),
required: z.boolean().default(false), label: z.string(),
options: z.array(z.string()).optional(), required: z.boolean().default(false),
settings: z.record(z.string(), z.any()).optional(), options: z.array(z.string()).optional(),
}), settings: z.record(z.string(), z.any()).optional(),
).optional(), }),
)
.optional(),
settings: z.record(z.string(), z.any()).optional(), settings: z.record(z.string(), z.any()).optional(),
}), }),
) )
@@ -275,10 +287,12 @@ export const formsRouter = createTRPCRouter({
}); });
} }
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [ await checkStudyAccess(
"owner", ctx.db,
"researcher", ctx.session.user.id,
]); existingForm.studyId,
["owner", "researcher"],
);
const [updatedForm] = await ctx.db const [updatedForm] = await ctx.db
.update(forms) .update(forms)
@@ -407,10 +421,12 @@ export const formsRouter = createTRPCRouter({
}); });
} }
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [ await checkStudyAccess(
"owner", ctx.db,
"researcher", ctx.session.user.id,
]); existingForm.studyId,
["owner", "researcher"],
);
const latestForm = await ctx.db.query.forms.findFirst({ const latestForm = await ctx.db.query.forms.findFirst({
where: eq(forms.studyId, existingForm.studyId), where: eq(forms.studyId, existingForm.studyId),
@@ -517,6 +533,81 @@ export const formsRouter = createTRPCRouter({
}; };
}), }),
exportCsv: protectedProcedure
.input(z.object({ formId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: eq(forms.id, input.formId),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
await checkStudyAccess(ctx.db, ctx.session.user.id, form.studyId);
const responses = await ctx.db.query.formResponses.findMany({
where: eq(formResponses.formId, input.formId),
with: {
participant: {
columns: {
id: true,
participantCode: true,
name: true,
email: true,
},
},
},
orderBy: [desc(formResponses.submittedAt)],
});
const fields = form.fields as Array<{
id: string;
label: string;
type: string;
}>;
const headers = [
"Participant Code",
"Name",
"Email",
"Status",
"Submitted At",
...fields.map((f) => f.label),
];
const rows = responses.map((r) => {
const participantResponses = r.responses as Record<string, any>;
return [
r.participant?.participantCode ?? "",
r.participant?.name ?? "",
r.participant?.email ?? "",
r.status,
r.submittedAt?.toISOString() ?? "",
...fields.map((f) => {
const val = participantResponses[f.id];
if (val === undefined || val === null) return "";
if (typeof val === "boolean") return val ? "Yes" : "No";
return String(val);
}),
];
});
const escape = (s: string | null | undefined) =>
`"${String(s ?? "").replace(/"/g, '""')}"`;
const csv = [
headers.map((h) => escape(h)).join(","),
...rows.map((row) => row.map((cell) => escape(cell)).join(",")),
].join("\n");
return {
csv,
filename: `${form.title.replace(/\s+/g, "_")}_responses.csv`,
};
}),
submitResponse: protectedProcedure submitResponse: protectedProcedure
.input( .input(
z.object({ z.object({
@@ -596,22 +687,24 @@ export const formsRouter = createTRPCRouter({
.select({ count: count() }) .select({ count: count() })
.from(formResponses) .from(formResponses)
.where(eq(formResponses.formId, form.id)); .where(eq(formResponses.formId, form.id));
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } }; return {
}) ...form,
_count: { responses: responseCount[0]?.count ?? 0 },
};
}),
); );
return formsWithCounts; return formsWithCounts;
}), }),
listTemplates: protectedProcedure listTemplates: protectedProcedure.query(async ({ ctx }) => {
.query(async ({ ctx }) => { const templates = await ctx.db.query.forms.findMany({
const templates = await ctx.db.query.forms.findMany({ where: eq(forms.isTemplate, true),
where: eq(forms.isTemplate, true), orderBy: [desc(forms.updatedAt)],
orderBy: [desc(forms.updatedAt)], });
});
return templates; return templates;
}), }),
createFromTemplate: protectedProcedure createFromTemplate: protectedProcedure
.input( .input(
@@ -628,10 +721,7 @@ export const formsRouter = createTRPCRouter({
]); ]);
const template = await ctx.db.query.forms.findFirst({ const template = await ctx.db.query.forms.findFirst({
where: and( where: and(eq(forms.id, input.templateId), eq(forms.isTemplate, true)),
eq(forms.id, input.templateId),
eq(forms.isTemplate, true),
),
}); });
if (!template) { if (!template) {
@@ -673,4 +763,101 @@ export const formsRouter = createTRPCRouter({
return newForm; return newForm;
}), }),
});
getPublic: publicProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const form = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, input.id), eq(forms.active, true)),
columns: {
id: true,
studyId: true,
type: true,
title: true,
description: true,
version: true,
fields: true,
settings: true,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found or not active",
});
}
const study = await ctx.db.query.studies.findFirst({
where: eq(studies.id, form.studyId),
columns: {
name: true,
},
});
return { ...form, studyName: study?.name };
}),
submitPublic: publicProcedure
.input(
z.object({
formId: z.string().uuid(),
participantCode: z.string().min(1).max(100),
responses: z.record(z.string(), z.any()),
}),
)
.mutation(async ({ ctx, input }) => {
const { formId, participantCode, responses } = input;
const form = await ctx.db.query.forms.findFirst({
where: and(eq(forms.id, formId), eq(forms.active, true)),
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found or not active",
});
}
const participant = await ctx.db.query.participants.findFirst({
where: and(
eq(participants.studyId, form.studyId),
eq(participants.participantCode, participantCode),
),
});
if (!participant) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invalid participant code",
});
}
const existingResponse = await ctx.db.query.formResponses.findFirst({
where: and(
eq(formResponses.formId, formId),
eq(formResponses.participantId, participant.id),
),
});
if (existingResponse) {
throw new TRPCError({
code: "CONFLICT",
message: "You have already submitted this form",
});
}
const [newResponse] = await ctx.db
.insert(formResponses)
.values({
formId,
participantId: participant.id,
responses,
status: "completed",
})
.returning();
return newResponse;
}),
});