mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
Compare commits
1 Commits
3270e3f8fe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3959cf23f7 |
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,
|
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 <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(() => {
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
516
src/app/page.tsx
516
src/app/page.tsx
@@ -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">
|
||||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
© {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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user