diff --git a/app/(app)/forms/[id]/edit/page.tsx b/app/(app)/forms/[id]/edit/page.tsx new file mode 100644 index 0000000..c604a7d --- /dev/null +++ b/app/(app)/forms/[id]/edit/page.tsx @@ -0,0 +1,44 @@ +import { notFound } from 'next/navigation' +import { requireFormsManagerPage } from '@/lib/forms-guard' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { FormBuilder } from '@/components/forms/form-builder' +import { parseFormSchema } from '@/lib/forms-schema' + +export const dynamic = 'force-dynamic' + +export default async function EditFormPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const { supabase, member } = await requireFormsManagerPage() + + const { data: form } = await supabase + .from('forms') + .select('id, slug, title, description, kind, visibility, status, legal_text, schema, space_id') + .eq('id', id) + .eq('space_id', member.space_id) + .single() + + if (!form) notFound() + + return ( + <> + + Edit form + +
+ +
+ + ) +} diff --git a/app/(app)/forms/[id]/results/page.tsx b/app/(app)/forms/[id]/results/page.tsx new file mode 100644 index 0000000..a2bc01d --- /dev/null +++ b/app/(app)/forms/[id]/results/page.tsx @@ -0,0 +1,36 @@ +import { notFound } from 'next/navigation' +import { requireFormsManagerPage } from '@/lib/forms-guard' +import { parseFormSchema } from '@/lib/forms-schema' +import { ResultsClient } from './results-client' + +export const dynamic = 'force-dynamic' + +export default async function FormResultsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params + const { supabase, member } = await requireFormsManagerPage() + + const { data: form } = await supabase + .from('forms') + .select('id, slug, title, kind, schema') + .eq('id', id) + .eq('space_id', member.space_id) + .single() + if (!form) notFound() + + const { data: submissions } = await supabase + .from('form_submissions') + .select('id, member_id, submitter_email, answers, form_version, created_at') + .eq('form_id', id) + .eq('space_id', member.space_id) + .order('created_at', { ascending: false }) + .limit(5000) + + return ( + + ) +} diff --git a/app/(app)/forms/[id]/results/results-client.tsx b/app/(app)/forms/[id]/results/results-client.tsx new file mode 100644 index 0000000..16de597 --- /dev/null +++ b/app/(app)/forms/[id]/results/results-client.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useState } from 'react' +import Link from 'next/link' +import { ArrowLeft, Download } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Button } from '@/components/ui/button' +import { Empty, EmptyHeader, EmptyTitle, EmptyDescription } from '@/components/ui/empty' +import { toast } from 'sonner' +import { exportFormResultsCsv } from '@/lib/actions' +import type { FormField } from '@/lib/forms-schema' + +type Submission = { + id: string + member_id: string | null + submitter_email: string | null + answers: Record | null + form_version: number + created_at: string +} + +function cell(v: unknown): string { + if (v === null || v === undefined) return '' + return typeof v === 'string' ? v : JSON.stringify(v) +} + +export function ResultsClient({ + formId, + title, + fields, + submissions, +}: { + formId: string + title: string + fields: FormField[] + submissions: Submission[] +}) { + const [exporting, setExporting] = useState(false) + + async function exportCsv() { + setExporting(true) + const res = await exportFormResultsCsv({ formId }) + setExporting(false) + if ('error' in res && res.error) { + toast.error(res.error || 'Export failed') + return + } + const { filename, csv } = res.data + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } + + return ( + <> + +
+ + {title} — responses +
+ +
+ +
+ {submissions.length === 0 ? ( + + + No responses yet + Submissions will appear here as they come in. + + + ) : ( +
+ + + + + + {fields.map(f => ( + + ))} + + + + {submissions.map(s => { + const a = s.answers ?? {} + return ( + + + + {fields.map(f => ( + + ))} + + ) + })} + +
SubmittedSubmitter + {f.label} +
+ {new Date(s.created_at).toLocaleString()} + + {s.member_id ? 'Member' : s.submitter_email || 'Anonymous'} + + {cell(a[f.key])} +
+
+ )} +
+ + ) +} diff --git a/app/(app)/forms/forms-client.tsx b/app/(app)/forms/forms-client.tsx new file mode 100644 index 0000000..eedbd6b --- /dev/null +++ b/app/(app)/forms/forms-client.tsx @@ -0,0 +1,89 @@ +'use client' + +import Link from 'next/link' +import { FileText, Plus } from 'lucide-react' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' + +type FormRow = { + id: string + slug: string + title: string + kind: string + visibility: string + status: string + version: number + created_at: string +} + +const VIS_LABEL: Record = { + members: 'Members only', + public_auth: 'Signed in', + public_anon: 'Public', +} + +export function FormsClient({ forms }: { forms: FormRow[] }) { + return ( + <> + + Forms & waivers + + + +
+ {forms.length === 0 ? ( + + + + + + No forms yet + + Build a custom form or a signable waiver. Members-only or public, your choice. + + + + + ) : ( +
+ {forms.map(f => ( +
+
+
+ + {f.title} + + {f.kind === 'waiver' && Waiver} +
+

/f/{f.slug}

+
+ {VIS_LABEL[f.visibility] ?? f.visibility} + + {f.status} + +
+ + +
+
+ ))} +
+ )} +
+ + ) +} diff --git a/app/(app)/forms/new/page.tsx b/app/(app)/forms/new/page.tsx new file mode 100644 index 0000000..6c258e6 --- /dev/null +++ b/app/(app)/forms/new/page.tsx @@ -0,0 +1,30 @@ +import { requireFormsManagerPage } from '@/lib/forms-guard' +import { PageHeader, PageTitle } from '@/components/ui/page-title' +import { FormBuilder } from '@/components/forms/form-builder' + +export const dynamic = 'force-dynamic' + +export default async function NewFormPage() { + await requireFormsManagerPage() + + return ( + <> + + New form + +
+ +
+ + ) +} diff --git a/app/(app)/forms/page.tsx b/app/(app)/forms/page.tsx new file mode 100644 index 0000000..fd25938 --- /dev/null +++ b/app/(app)/forms/page.tsx @@ -0,0 +1,16 @@ +import { requireFormsManagerPage } from '@/lib/forms-guard' +import { FormsClient } from './forms-client' + +export const dynamic = 'force-dynamic' + +export default async function FormsPage() { + const { supabase, member } = await requireFormsManagerPage() + + const { data: forms } = await supabase + .from('forms') + .select('id, slug, title, kind, visibility, status, version, created_at') + .eq('space_id', member.space_id) + .order('created_at', { ascending: false }) + + return +} diff --git a/app/f/[slug]/page.tsx b/app/f/[slug]/page.tsx new file mode 100644 index 0000000..25e3b2d --- /dev/null +++ b/app/f/[slug]/page.tsx @@ -0,0 +1,43 @@ +import { createClient } from '@/lib/supabase/server' +import { getPublicForm } from '@/lib/actions' +import { parseFormSchema } from '@/lib/forms-schema' +import { PublicFormClient } from './public-form-client' + +export const dynamic = 'force-dynamic' + +export default async function PublicFormPage({ params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params + const res = await getPublicForm({ slug }) + + if ('error' in res && res.error) { + return ( +
+

This form is not available

+

+ It may be unpublished, closed, or the link is wrong. +

+
+ ) + } + + const form = res.data + const supabase = await createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + return ( +
+ +
+ ) +} diff --git a/app/f/[slug]/public-form-client.tsx b/app/f/[slug]/public-form-client.tsx new file mode 100644 index 0000000..9d86f21 --- /dev/null +++ b/app/f/[slug]/public-form-client.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { FormRenderer } from '@/components/forms/form-renderer' +import { submitForm } from '@/lib/actions' +import type { FormField } from '@/lib/forms-schema' + +export function PublicFormClient({ + slug, + title, + description, + kind, + visibility, + legalText, + fields, + authed, +}: { + slug: string + title: string + description: string | null + kind: string + visibility: string + legalText: string | null + fields: FormField[] + authed: boolean +}) { + const [values, setValues] = useState>({}) + const [email, setEmail] = useState('') + const [consent, setConsent] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState(false) + + const needsSignIn = visibility === 'public_auth' && !authed + const isWaiver = kind === 'waiver' + const showEmail = visibility === 'public_anon' + + async function submit() { + setError(null) + setSubmitting(true) + const res = await submitForm({ + slug, + answers: values, + email: showEmail && email ? email : undefined, + consent: isWaiver ? consent : undefined, + }) + setSubmitting(false) + if ('error' in res && res.error) { + setError(res.error) + return + } + setDone(true) + } + + if (done) { + return ( +
+

Thank you

+

Your response has been recorded.

+
+ ) + } + + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ + {needsSignIn ? ( +
+

You need to be signed in to submit this form.

+ +
+ ) : ( + <> + {isWaiver && legalText && ( +
+ {legalText} +
+ )} + + setValues(p => ({ ...p, [k]: v }))} /> + + {showEmail && ( +
+ + setEmail(e.target.value)} + /> +

+ Used to link this to your account if you join later. +

+
+ )} + + {isWaiver && ( + + )} + + {error &&

{error}

} + + + + )} +
+ ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 1695cbc..37c108f 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench, MessageSquare, Users, CreditCard, BookUser, Download, LogOut, Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch, - MessagesSquare, SlidersHorizontal, + MessagesSquare, SlidersHorizontal, ClipboardList, } from 'lucide-react' interface NavLinkProps { @@ -144,6 +144,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa

Admin

+ diff --git a/components/forms/form-builder.tsx b/components/forms/form-builder.tsx new file mode 100644 index 0000000..2f37bc7 --- /dev/null +++ b/components/forms/form-builder.tsx @@ -0,0 +1,443 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { ArrowUp, ArrowDown, Trash2, Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { SectionTitle } from '@/components/ui/page-title' +import { useConfirm } from '@/components/ui/confirm' +import { toast } from 'sonner' +import { + createForm, + updateForm, + setFormStatus, + deleteForm, +} from '@/lib/actions' +import { formFieldTypes } from '@/lib/validations' +import type { FormField } from '@/lib/forms-schema' +import { FormRenderer } from './form-renderer' + +type Kind = 'form' | 'waiver' +type Visibility = 'public_anon' | 'public_auth' | 'members' +type Status = 'draft' | 'published' | 'closed' + +export type BuilderForm = { + id?: string + slug: string + title: string + description: string + kind: Kind + visibility: Visibility + legal_text: string + status?: Status + schema: FormField[] +} + +const TYPE_LABELS: Record<(typeof formFieldTypes)[number], string> = { + short_text: 'Text', + long_text: 'Paragraph', + email: 'Email', + number: 'Number', + date: 'Date', + checkbox: 'Checkbox', + select: 'Dropdown', + radio: 'Choice', +} + +function slugify(s: string, sep: '-' | '_') { + const cleaned = s + .toLowerCase() + .replace(/[^a-z0-9]+/g, sep) + .replace(new RegExp(`^\\${sep}+|\\${sep}+$`, 'g'), '') + return cleaned +} + +// Keys are derived from labels so the builder only ever asks for a label. +// Duplicates and empties are made safe here, matching the server schema. +function deriveKeys(fields: FormField[]): FormField[] { + const seen = new Set() + return fields.map((f, i) => { + let base = slugify(f.label || '', '_') || `field_${i + 1}` + let key = base + let n = 2 + while (seen.has(key)) key = `${base}_${n++}` + seen.add(key) + return { ...f, key } + }) +} + +export function FormBuilder({ initial }: { initial: BuilderForm }) { + const router = useRouter() + const confirm = useConfirm() + const isEdit = Boolean(initial.id) + + const [slug, setSlug] = useState(initial.slug) + const [slugTouched, setSlugTouched] = useState(isEdit) + const [title, setTitle] = useState(initial.title) + const [description, setDescription] = useState(initial.description) + const [kind, setKind] = useState(initial.kind) + const [visibility, setVisibility] = useState(initial.visibility) + const [legalText, setLegalText] = useState(initial.legal_text) + const [fields, setFields] = useState(initial.schema) + const [preview, setPreview] = useState>({}) + const [saving, setSaving] = useState(false) + const [busy, setBusy] = useState(false) + + function onTitle(v: string) { + setTitle(v) + if (!isEdit && !slugTouched) setSlug(slugify(v, '-')) + } + + function addField(type: (typeof formFieldTypes)[number]) { + setFields(f => [ + ...f, + { + key: '', + type, + label: `Untitled ${TYPE_LABELS[type]}`, + required: false, + options: type === 'select' || type === 'radio' ? ['Option 1'] : undefined, + }, + ]) + } + + function patch(i: number, p: Partial) { + setFields(f => f.map((x, idx) => (idx === i ? { ...x, ...p } : x))) + } + + function move(i: number, dir: -1 | 1) { + setFields(f => { + const j = i + dir + if (j < 0 || j >= f.length) return f + const next = [...f] + ;[next[i], next[j]] = [next[j], next[i]] + return next + }) + } + + async function removeField(i: number) { + const ok = await confirm({ + title: 'Remove this field?', + description: 'It will be gone from the form. Save to make it permanent.', + destructive: true, + confirmText: 'Remove', + }) + if (ok) setFields(f => f.filter((_, idx) => idx !== i)) + } + + async function save() { + if (!title.trim()) { + toast.error('Title is required') + return + } + setSaving(true) + const schema = deriveKeys(fields) + const common = { + title: title.trim(), + description: description.trim() || null, + visibility, + schema, + legal_text: kind === 'waiver' ? legalText : null, + } + const res = isEdit + ? await updateForm({ formId: initial.id, ...common }) + : await createForm({ slug, kind, ...common }) + setSaving(false) + + if ('error' in res && res.error) { + toast.error(res.error || 'Could not save') + return + } + toast.success(isEdit ? 'Form saved' : 'Form created') + router.push('/forms') + router.refresh() + } + + async function changeStatus(status: Status) { + setBusy(true) + const res = await setFormStatus({ formId: initial.id, status }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error || 'Could not update') + return + } + toast.success(`Form ${status}`) + router.refresh() + } + + async function remove() { + const ok = await confirm({ + title: 'Delete this form?', + description: 'This cannot be undone. Forms with submissions cannot be deleted.', + destructive: true, + confirmText: 'Delete', + }) + if (!ok) return + setBusy(true) + const res = await deleteForm({ formId: initial.id }) + setBusy(false) + if ('error' in res && res.error) { + toast.error(res.error || 'Could not delete') + return + } + toast.success('Form deleted') + router.push('/forms') + router.refresh() + } + + const publicUrl = + typeof window !== 'undefined' ? `${window.location.origin}/f/${initial.slug}` : `/f/${initial.slug}` + + return ( +
+ {/* Editor */} +
+ {isEdit && ( +
+ + {initial.status} + + {initial.status !== 'published' && ( + + )} + {initial.status === 'published' && ( + + )} + {initial.status !== 'draft' && ( + + )} + +
+ )} + + {isEdit && initial.status === 'published' && visibility !== 'members' && ( +
+ {publicUrl} + +
+ )} + +
+ Form details +
+ + onTitle(e.target.value)} /> +
+
+ + { + setSlugTouched(true) + setSlug(e.target.value) + }} + /> + {isEdit ? ( +

The slug cannot change after creation.

+ ) : ( +

Public address: /f/{slug || '…'}

+ )} +
+
+ +