mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
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:
28
public/images/screenshots/README.md
Normal file
28
public/images/screenshots/README.md
Normal 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"
|
||||
/>
|
||||
```
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
Edit2,
|
||||
Users,
|
||||
CheckCircle,
|
||||
Printer,
|
||||
Download,
|
||||
Pencil,
|
||||
X,
|
||||
FileDown,
|
||||
} from "lucide-react";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -81,8 +86,16 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
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 [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 [description, setDescription] = useState("");
|
||||
@@ -96,6 +109,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: participants } = api.participants.list.useQuery(
|
||||
{ studyId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id && isEnteringData },
|
||||
);
|
||||
|
||||
const { data: study } = api.studies.get.useQuery(
|
||||
{ id: 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 <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 || ""} |
|
||||
<strong>Form Type:</strong> ${form?.type} |
|
||||
<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(() => {
|
||||
if (form) {
|
||||
setTitle(form.title);
|
||||
@@ -147,26 +302,28 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
|
||||
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 addField = (type: string) => {
|
||||
const newField: Field = {
|
||||
id: crypto.randomUUID(),
|
||||
type,
|
||||
label: `New ${fieldTypes.find(f => f.value === type)?.label || "Field"}`,
|
||||
label: `New ${fieldTypes.find((f) => f.value === type)?.label || "Field"}`,
|
||||
required: false,
|
||||
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
options:
|
||||
type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
|
||||
};
|
||||
setFields([...fields, newField]);
|
||||
};
|
||||
|
||||
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>) => {
|
||||
setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
|
||||
setFields(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -191,10 +348,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
</Button>
|
||||
<div>
|
||||
<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>
|
||||
{form.active && (
|
||||
<Badge variant="default" className="text-xs">Active</Badge>
|
||||
<Badge variant="default" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm capitalize">
|
||||
@@ -215,10 +374,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
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>
|
||||
)}
|
||||
@@ -228,6 +397,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<TabsList>
|
||||
<TabsTrigger value="fields">Fields</TabsTrigger>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="data-entry">Data Entry</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="responses">
|
||||
Responses ({responses.length})
|
||||
</TabsTrigger>
|
||||
@@ -254,7 +426,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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" />
|
||||
<p>No fields added yet</p>
|
||||
</div>
|
||||
@@ -265,18 +437,26 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
key={field.id}
|
||||
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" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(field.id, { label: e.target.value })}
|
||||
onChange={(e) =>
|
||||
updateField(field.id, { label: e.target.value })
|
||||
}
|
||||
placeholder="Field label"
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -284,7 +464,11 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<input
|
||||
type="checkbox"
|
||||
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"
|
||||
/>
|
||||
Required
|
||||
@@ -294,13 +478,20 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Options</Label>
|
||||
{field.options?.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={opt}
|
||||
onChange={(e) => {
|
||||
const newOptions = [...(field.options || [])];
|
||||
const newOptions = [
|
||||
...(field.options || []),
|
||||
];
|
||||
newOptions[i] = e.target.value;
|
||||
updateField(field.id, { options: newOptions });
|
||||
updateField(field.id, {
|
||||
options: newOptions,
|
||||
});
|
||||
}}
|
||||
placeholder={`Option ${i + 1}`}
|
||||
className="flex-1"
|
||||
@@ -310,8 +501,12 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const newOptions = field.options?.filter((_, idx) => idx !== i);
|
||||
updateField(field.id, { options: newOptions });
|
||||
const newOptions = field.options?.filter(
|
||||
(_, idx) => idx !== i,
|
||||
);
|
||||
updateField(field.id, {
|
||||
options: newOptions,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -323,8 +518,13 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`];
|
||||
updateField(field.id, { options: newOptions });
|
||||
const newOptions = [
|
||||
...(field.options || []),
|
||||
`Option ${(field.options?.length || 0) + 1}`,
|
||||
];
|
||||
updateField(field.id, {
|
||||
options: newOptions,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
@@ -339,7 +539,7 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
size="icon"
|
||||
onClick={() => removeField(field.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<Trash2 className="text-destructive h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -358,16 +558,23 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs">
|
||||
<div
|
||||
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}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{field.label}</p>
|
||||
<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.type === "multiple_choice" && ` • ${field.options?.length} options`}
|
||||
{field.type === "multiple_choice" &&
|
||||
` • ${field.options?.length} options`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,7 +594,9 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
{fields.length === 0 ? (
|
||||
<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">
|
||||
<Label>
|
||||
{index + 1}. {field.label}
|
||||
{field.required && <span className="text-destructive"> *</span>}
|
||||
{field.required && (
|
||||
<span className="text-destructive"> *</span>
|
||||
)}
|
||||
</Label>
|
||||
{field.type === "text" && (
|
||||
<Input placeholder="Enter your response..." disabled />
|
||||
)}
|
||||
{field.type === "textarea" && (
|
||||
<Textarea placeholder="Enter your response..." disabled />
|
||||
<Textarea
|
||||
placeholder="Enter your response..."
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
{field.type === "multiple_choice" && (
|
||||
<div className="space-y-2">
|
||||
@@ -431,18 +645,24 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
)}
|
||||
{field.type === "rating" && (
|
||||
<div className="flex gap-2">
|
||||
{Array.from({ length: field.settings?.scale || 5 }, (_, i) => (
|
||||
<button key={i} type="button" className="h-8 w-8 rounded border disabled" disabled>
|
||||
{Array.from(
|
||||
{ length: field.settings?.scale || 5 },
|
||||
(_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="disabled h-8 w-8 rounded border"
|
||||
disabled
|
||||
>
|
||||
{i + 1}
|
||||
</button>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{field.type === "date" && (
|
||||
<Input type="date" disabled />
|
||||
)}
|
||||
{field.type === "date" && <Input type="date" disabled />}
|
||||
{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)
|
||||
</div>
|
||||
)}
|
||||
@@ -454,14 +674,263 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="responses">
|
||||
<TabsContent value="data-entry">
|
||||
<Card>
|
||||
<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>
|
||||
{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>
|
||||
<CardContent>
|
||||
{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" />
|
||||
<p>No responses yet</p>
|
||||
</div>
|
||||
@@ -469,27 +938,35 @@ export default function FormViewPage({ params }: FormViewPageProps) {
|
||||
<div className="space-y-4">
|
||||
{responses.map((response) => (
|
||||
<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">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<Users className="text-muted-foreground h-4 w-4" />
|
||||
<span className="font-medium">
|
||||
{response.participant?.name || response.participant?.participantCode || "Unknown"}
|
||||
{response.participant?.name ||
|
||||
response.participant?.participantCode ||
|
||||
"Unknown"}
|
||||
</span>
|
||||
</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}
|
||||
</Badge>
|
||||
</div>
|
||||
<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">
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{key}:
|
||||
</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{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()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
FileQuestion,
|
||||
FileSignature,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
@@ -40,9 +39,11 @@ const formTypeIcons = {
|
||||
};
|
||||
|
||||
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",
|
||||
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 {
|
||||
@@ -55,7 +56,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,10 +137,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
|
||||
{forms.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Forms Yet</h3>
|
||||
<FileText className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No Forms Yet</h3>
|
||||
<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>
|
||||
{canManage && (
|
||||
<Button asChild>
|
||||
@@ -151,8 +155,8 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search forms..."
|
||||
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">
|
||||
{forms.map((form) => {
|
||||
const TypeIcon = formTypeIcons[form.type as keyof typeof formTypeIcons] || FileText;
|
||||
const typeColor = formTypeColors[form.type as keyof typeof formTypeColors] || "bg-gray-100";
|
||||
const TypeIcon =
|
||||
formTypeIcons[form.type as keyof typeof formTypeIcons] ||
|
||||
FileText;
|
||||
const typeColor =
|
||||
formTypeColors[form.type as keyof typeof formTypeColors] ||
|
||||
"bg-gray-100";
|
||||
const isActive = form.active;
|
||||
|
||||
return (
|
||||
@@ -177,7 +185,9 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
<TypeIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{form.title}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{form.title}
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-xs capitalize">
|
||||
{form.type}
|
||||
</p>
|
||||
@@ -192,18 +202,22 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent className="pb-3">
|
||||
{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}
|
||||
</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>{(form as any)._count?.responses ?? 0} responses</span>
|
||||
<span>
|
||||
{(form as any)._count?.responses ?? 0} responses
|
||||
</span>
|
||||
</div>
|
||||
</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">
|
||||
<Link href={`/studies/${resolvedParams?.id}/forms/${form.id}`}>
|
||||
<Link
|
||||
href={`/studies/${resolvedParams?.id}/forms/${form.id}`}
|
||||
>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
@@ -216,15 +230,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setActiveMutation.mutate({ id: form.id })}
|
||||
onClick={() =>
|
||||
setActiveMutation.mutate({ id: form.id })
|
||||
}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Set Active
|
||||
@@ -232,7 +242,11 @@ export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
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 });
|
||||
}
|
||||
}}
|
||||
|
||||
430
src/app/(public)/forms/[formId]/page.tsx
Normal file
430
src/app/(public)/forms/[formId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
446
src/app/page.tsx
446
src/app/page.tsx
@@ -1,4 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -8,18 +9,41 @@ import { Logo } from "~/components/ui/logo";
|
||||
import { auth } from "~/lib/auth";
|
||||
import {
|
||||
ArrowRight,
|
||||
Beaker,
|
||||
Bot,
|
||||
Database,
|
||||
LayoutTemplate,
|
||||
Lock,
|
||||
Network,
|
||||
PlayCircle,
|
||||
Settings2,
|
||||
Share2,
|
||||
Sparkles,
|
||||
Users,
|
||||
Beaker,
|
||||
FileText,
|
||||
PlayCircle,
|
||||
} 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() {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
@@ -40,7 +64,7 @@ export default async function Home() {
|
||||
<Link href="#features">Features</Link>
|
||||
</Button>
|
||||
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
||||
<Link href="#architecture">Architecture</Link>
|
||||
<Link href="#how-it-works">How It Works</Link>
|
||||
</Button>
|
||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||
<Button variant="ghost" asChild>
|
||||
@@ -55,8 +79,7 @@ export default async function Home() {
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
|
||||
{/* Background Gradients */}
|
||||
<section className="relative overflow-hidden pt-20 pb-24 md:pt-32">
|
||||
<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">
|
||||
@@ -65,26 +88,27 @@ export default async function Home() {
|
||||
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" />
|
||||
The Modern Standard for HRI Research
|
||||
Open Source WoZ Platform
|
||||
</Badge>
|
||||
|
||||
<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" />
|
||||
<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">
|
||||
Made Simple
|
||||
Wizard-of-Oz Studies <br className="hidden md:block" />
|
||||
<span className="bg-gradient-to-r from-cyan-500 to-blue-600 bg-clip-text text-transparent">
|
||||
Made Scientific
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<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
|
||||
ease of use and scientific rigor. Design, execute, and analyze
|
||||
human-robot interaction experiments with zero friction.
|
||||
HRIStudio is the open-source platform that makes human-robot
|
||||
interaction research reproducible, accessible, and collaborative.
|
||||
Design experiments, control robots, and analyze results — all in
|
||||
one place.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<Link href="/auth/signup">
|
||||
Start Researching
|
||||
Start Your Research
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -102,127 +126,160 @@ export default async function Home() {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mockup / Visual Interest */}
|
||||
<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">
|
||||
<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="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||
{/* Placeholder for actual app screenshot */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
|
||||
<div className="p-8 text-center">
|
||||
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" />
|
||||
<p className="text-muted-foreground font-medium">
|
||||
Interactive Experiment Designer
|
||||
{/* Screenshots Section */}
|
||||
<section id="screenshots" className="container mx-auto px-4 py-12">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{screenshots.map((screenshot, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`group bg-muted/50 relative overflow-hidden rounded-xl border ${screenshot.className}`}
|
||||
>
|
||||
{/* Placeholder - replace src with actual screenshot */}
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Features Bento Grid */}
|
||||
<section id="features" className="container mx-auto px-4 py-24">
|
||||
<div className="mb-12 text-center">
|
||||
{/* How It Works */}
|
||||
<section id="how-it-works" className="container mx-auto px-4 py-24">
|
||||
<div className="mb-16 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Everything You Need
|
||||
How It Works
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
|
||||
{/* Visual Designer - Large Item */}
|
||||
<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">
|
||||
<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 className="relative">
|
||||
{/* Connection line */}
|
||||
<div className="bg-border absolute top-0 left-1/2 hidden h-full w-px -translate-x-1/2 lg:block" />
|
||||
|
||||
<div className="space-y-12 lg:space-y-0">
|
||||
<WorkflowStep
|
||||
number={1}
|
||||
title="Design"
|
||||
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."
|
||||
icon={LayoutTemplate}
|
||||
/>
|
||||
<WorkflowStep
|
||||
number={2}
|
||||
title="Configure"
|
||||
description="Set up your study, invite team members with appropriate roles, and configure your robot platform."
|
||||
icon={Settings2}
|
||||
/>
|
||||
<WorkflowStep
|
||||
number={3}
|
||||
title="Execute"
|
||||
description="Run trials with the wizard interface. Real-time updates keep everyone in sync. Every action is automatically logged."
|
||||
icon={PlayCircle}
|
||||
/>
|
||||
<WorkflowStep
|
||||
number={4}
|
||||
title="Analyze"
|
||||
description="Review trial data, export responses, and compare across participants. Everything is timestamped and synchronized."
|
||||
icon={Share2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Robot Agnostic */}
|
||||
<Card className="col-span-1 md:col-span-1 lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-green-500" />
|
||||
Robot Agnostic
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Switch between robots instantly. Whether it's a NAO, Pepper,
|
||||
or a custom ROS2 bot, your experiment logic remains strictly
|
||||
separated from hardware implementation.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role Based */}
|
||||
<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">
|
||||
<Lock className="h-4 w-4 text-orange-500" />
|
||||
Role-Based Access
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Architecture Section */}
|
||||
<section id="architecture" className="bg-muted/30 border-t py-24">
|
||||
<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>
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Enterprise-Grade Architecture
|
||||
Modern Architecture
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-4 text-lg">
|
||||
Designed for reliability and scale. HRIStudio uses a modern
|
||||
stack to ensure your data is safe and your experiments run
|
||||
smoothly.
|
||||
Built on proven technologies for reliability, type safety, and
|
||||
real-time collaboration.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
@@ -232,9 +289,9 @@ export default async function Home() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">3-Layer Design</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Clear separation between UI, Data, and Hardware layers
|
||||
for maximum stability.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
UI, application logic, and hardware layers are strictly
|
||||
separated for stability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,94 +300,108 @@ export default async function Home() {
|
||||
<Share2 className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">
|
||||
Collaborative by Default
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Real-time state synchronization allows multiple
|
||||
researchers to monitor a single trial.
|
||||
<h3 className="font-semibold">Real-Time Sync</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
WebSocket updates keep wizard and observer views
|
||||
perfectly synchronized.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<Settings2 className="text-primary h-5 w-5" />
|
||||
<Beaker className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">ROS2 Integration</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Native support for ROS2 nodes, topics, and actions right
|
||||
out of the box.
|
||||
<h3 className="font-semibold">Plugin System</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Extend with custom robot integrations and actions
|
||||
through a simple JSON configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[500px]">
|
||||
{/* Abstract representation of architecture */}
|
||||
<div className="relative z-10 space-y-4">
|
||||
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2">
|
||||
<div className="relative space-y-4">
|
||||
<Card className="border-blue-500/20 bg-blue-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
||||
APP LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">
|
||||
Next.js Dashboard + Experiment Designer
|
||||
<p className="text-sm font-medium">
|
||||
Next.js + React + tRPC
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Type-safe full-stack
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6">
|
||||
<Card className="border-violet-500/20 bg-violet-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
|
||||
DATA LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">
|
||||
PostgreSQL + MinIO + TRPC API
|
||||
<p className="text-sm font-medium">
|
||||
PostgreSQL + MinIO + WebSocket
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Persistent storage + real-time
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10">
|
||||
<Card className="border-green-500/20 bg-green-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
|
||||
HARDWARE LAYER
|
||||
ROBOT LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">
|
||||
ROS2 Bridge + Robot Plugins
|
||||
<p className="text-sm font-medium">
|
||||
ROS2 Bridge + Plugin Config
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Platform agnostic
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Decorative blobs */}
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="container mx-auto px-4 py-24 text-center">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Ready to upgrade your lab?
|
||||
Ready to upgrade your research?
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
||||
Join the community of researchers building the future of HRI with
|
||||
reproducible, open-source tools.
|
||||
<p className="text-muted-foreground mt-4 text-lg">
|
||||
Join researchers building reproducible HRI studies with
|
||||
open-source tools.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="mt-8 flex flex-col gap-4 sm:flex-row sm:justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
|
||||
asChild
|
||||
>
|
||||
<Link href="/auth/signup">Get Started for Free</Link>
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
@@ -340,25 +411,96 @@ export default async function Home() {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Logo iconSize="sm" showText={true} />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
© {new Date().getFullYear()} HRIStudio. Open source under MIT
|
||||
License.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
GitHub
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
Documentation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
import { and, count, desc, eq, ilike, or } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import {
|
||||
activityLogs,
|
||||
formResponses,
|
||||
@@ -11,6 +15,7 @@ import {
|
||||
formFieldTypeEnum,
|
||||
participants,
|
||||
studyMembers,
|
||||
studies,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
@@ -116,8 +121,11 @@ export const formsRouter = createTRPCRouter({
|
||||
.select({ count: count() })
|
||||
.from(formResponses)
|
||||
.where(eq(formResponses.formId, form.id));
|
||||
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
|
||||
})
|
||||
return {
|
||||
...form,
|
||||
_count: { responses: responseCount[0]?.count ?? 0 },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -178,7 +186,8 @@ export const formsRouter = createTRPCRouter({
|
||||
type: z.enum(formTypes),
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
fields: z.array(
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
@@ -187,7 +196,8 @@ export const formsRouter = createTRPCRouter({
|
||||
options: z.array(z.string()).optional(),
|
||||
settings: z.record(z.string(), z.any()).optional(),
|
||||
}),
|
||||
).default([]),
|
||||
)
|
||||
.default([]),
|
||||
settings: z.record(z.string(), z.any()).optional(),
|
||||
isTemplate: z.boolean().optional(),
|
||||
templateName: z.string().max(100).optional(),
|
||||
@@ -248,7 +258,8 @@ export const formsRouter = createTRPCRouter({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
fields: z.array(
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
@@ -257,7 +268,8 @@ export const formsRouter = createTRPCRouter({
|
||||
options: z.array(z.string()).optional(),
|
||||
settings: z.record(z.string(), z.any()).optional(),
|
||||
}),
|
||||
).optional(),
|
||||
)
|
||||
.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, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
await checkStudyAccess(
|
||||
ctx.db,
|
||||
ctx.session.user.id,
|
||||
existingForm.studyId,
|
||||
["owner", "researcher"],
|
||||
);
|
||||
|
||||
const [updatedForm] = await ctx.db
|
||||
.update(forms)
|
||||
@@ -407,10 +421,12 @@ export const formsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await checkStudyAccess(ctx.db, ctx.session.user.id, existingForm.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
await checkStudyAccess(
|
||||
ctx.db,
|
||||
ctx.session.user.id,
|
||||
existingForm.studyId,
|
||||
["owner", "researcher"],
|
||||
);
|
||||
|
||||
const latestForm = await ctx.db.query.forms.findFirst({
|
||||
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
|
||||
.input(
|
||||
z.object({
|
||||
@@ -596,15 +687,17 @@ export const formsRouter = createTRPCRouter({
|
||||
.select({ count: count() })
|
||||
.from(formResponses)
|
||||
.where(eq(formResponses.formId, form.id));
|
||||
return { ...form, _count: { responses: responseCount[0]?.count ?? 0 } };
|
||||
})
|
||||
return {
|
||||
...form,
|
||||
_count: { responses: responseCount[0]?.count ?? 0 },
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return formsWithCounts;
|
||||
}),
|
||||
|
||||
listTemplates: protectedProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
listTemplates: protectedProcedure.query(async ({ ctx }) => {
|
||||
const templates = await ctx.db.query.forms.findMany({
|
||||
where: eq(forms.isTemplate, true),
|
||||
orderBy: [desc(forms.updatedAt)],
|
||||
@@ -628,10 +721,7 @@ export const formsRouter = createTRPCRouter({
|
||||
]);
|
||||
|
||||
const template = await ctx.db.query.forms.findFirst({
|
||||
where: and(
|
||||
eq(forms.id, input.templateId),
|
||||
eq(forms.isTemplate, true),
|
||||
),
|
||||
where: and(eq(forms.id, input.templateId), eq(forms.isTemplate, true)),
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
@@ -673,4 +763,101 @@ export const formsRouter = createTRPCRouter({
|
||||
|
||||
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;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user