From 938002a3bbd6a201266815d348ceb50b8da2900d Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 30 Oct 2026 21:27:56 +0000 Subject: [PATCH] feat(surveys): add consent-aware survey invitations --- app/(app)/dashboard/surveys/[id]/page.tsx | 144 ++++++ .../surveys/[id]/send-invite-form.tsx | 46 ++ .../dashboard/surveys/[id]/status-button.tsx | 31 ++ app/(app)/dashboard/surveys/new/form.tsx | 102 +++++ app/(app)/dashboard/surveys/new/page.tsx | 38 ++ app/(app)/dashboard/surveys/page.tsx | 87 ++++ app/(app)/layout.tsx | 1 + app/actions/surveys.ts | 430 ++++++++++++++++++ app/surveys/[slug]/invite/form.tsx | 46 ++ app/surveys/[slug]/invite/page.tsx | 32 ++ app/surveys/page.tsx | 27 ++ app/surveys/r/[token]/form.tsx | 41 ++ app/surveys/r/[token]/page.tsx | 37 ++ .../migrations/20261030200000_surveys.sql | 88 ++++ 14 files changed, 1150 insertions(+) create mode 100644 app/(app)/dashboard/surveys/[id]/page.tsx create mode 100644 app/(app)/dashboard/surveys/[id]/send-invite-form.tsx create mode 100644 app/(app)/dashboard/surveys/[id]/status-button.tsx create mode 100644 app/(app)/dashboard/surveys/new/form.tsx create mode 100644 app/(app)/dashboard/surveys/new/page.tsx create mode 100644 app/(app)/dashboard/surveys/page.tsx create mode 100644 app/actions/surveys.ts create mode 100644 app/surveys/[slug]/invite/form.tsx create mode 100644 app/surveys/[slug]/invite/page.tsx create mode 100644 app/surveys/page.tsx create mode 100644 app/surveys/r/[token]/form.tsx create mode 100644 app/surveys/r/[token]/page.tsx create mode 100644 supabase/migrations/20261030200000_surveys.sql diff --git a/app/(app)/dashboard/surveys/[id]/page.tsx b/app/(app)/dashboard/surveys/[id]/page.tsx new file mode 100644 index 00000000..7f3ab65e --- /dev/null +++ b/app/(app)/dashboard/surveys/[id]/page.tsx @@ -0,0 +1,144 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { SurveyStatusButton } from "./status-button"; +import { SurveyInviteForm } from "./send-invite-form"; + +export const metadata = { title: "Survey results · CrawlProof" }; + +export default async function SurveyDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + const svc = serviceClient(); + const { data: survey } = await svc.from("surveys") + .select("id, organization_id, project_id, slug, title, question, answer_type, status, variants, created_at") + .eq("id", id) + .maybeSingle(); + if (!survey) notFound(); + const { data: owner } = await svc.from("organization_members") + .select("id") + .eq("organization_id", survey.organization_id) + .eq("user_id", user.id) + .eq("role", "owner") + .maybeSingle(); + if (!owner) notFound(); + + const [{ data: project }, { data: inviteRows }, { data: responseRows }] = await Promise.all([ + svc.from("projects").select("name, url").eq("id", survey.project_id).maybeSingle(), + svc.from("survey_invites").select("id, variant_key, email_consent, sms_consent, requested_channel, delivery_channel, delivery_status, delivery_error, created_at").eq("survey_id", id).order("created_at", { ascending: false }).limit(500), + svc.from("survey_responses").select("id, invite_id, variant_key, rating, answer_text, created_at").eq("survey_id", id).order("created_at", { ascending: false }).limit(500), + ]); + const invites = (inviteRows ?? []) as Array<{ + id: string; variant_key: string; + email_consent: boolean; sms_consent: boolean; requested_channel: string; delivery_channel: string | null; + delivery_status: string; delivery_error: string | null; created_at: string; + }>; + const responses = (responseRows ?? []) as Array<{ + id: string; invite_id: string; variant_key: string; rating: number | null; + answer_text: string | null; created_at: string; + }>; + const responseByInvite = new Map(responses.map((response) => [response.invite_id, response])); + const variants = (Array.isArray(survey.variants) ? survey.variants : []) as Array<{ key: string; subject: string; message: string }>; + const inviteUrl = `${process.env.NEXT_PUBLIC_SITE_URL ?? "https://crawlproof.com"}/surveys/${survey.slug}/invite`; + + return ( +
+
+ ← Surveys +
+
+
+

{survey.title}

+ {survey.status} +
+

{project?.name ?? "Site"} · {project?.url ?? ""}

+

{survey.question}

+
+ +
+
+ +
+
+

Opt-in link

+

Share this link where people can choose email, SMS, or both. Each channel requires a separate opt-in.

+
+ {inviteUrl} +
+ +
+

Send one invitation

+

Use this when someone gives you their contact details and agrees to a survey invite. If SMS fails, email is used only when email consent is also checked.

+ +
+ +
+
+
+

Invitation A/B test

+

Stable recipient assignment · question stays the same between variants

+
+

{responses.length} response{responses.length === 1 ? "" : "s"} · {invites.length} invitation{invites.length === 1 ? "" : "s"}

+
+
+ + + + + + {variants.map((variant) => { + const assigned = invites.filter((invite) => invite.variant_key === variant.key); + const delivered = assigned.filter((invite) => invite.delivery_status === "sent" || invite.delivery_status === "responded").length; + const answered = responses.filter((response) => response.variant_key === variant.key).length; + const rate = delivered ? `${Math.round((answered / delivered) * 100)}%` : "—"; + return ( + + + + + + + + ); + })} + +
VariantInvitedDeliveredResponsesResponse rate
{variant.key}

{variant.message}

{assigned.length}{delivered}{answered}{rate}
+
+
+ +
+

Responses

Contact details are not shown in the response list.

+ {responses.length === 0 ?

No responses yet. Share the opt-in link or send an invitation.

: ( +
+ {responses.map((response) => { + const invite = invites.find((row) => row.id === response.invite_id); + return
+
Variant {response.variant_key}

{response.rating != null ? `${response.rating} / 5` : response.answer_text}

+ + {invite?.delivery_channel && via {invite.delivery_channel}} +
; + })} +
+ )} +
+ +
+

Invitation history

+ {invites.length === 0 ?

No invitations sent yet.

: ( +
+ + {invites.map((invite) => + + + + + )} +
RecipientVariantChannelStatusSent
{invite.sms_consent && invite.email_consent ? "SMS + email fallback" : invite.sms_consent ? "SMS opt-in" : "Email opt-in"}{invite.variant_key}{invite.delivery_channel ?? invite.requested_channel}{invite.delivery_status}{invite.delivery_error &&

{invite.delivery_error}

}
{new Date(invite.created_at).toLocaleString()}
+ )} +
+
+ ); +} diff --git a/app/(app)/dashboard/surveys/[id]/send-invite-form.tsx b/app/(app)/dashboard/surveys/[id]/send-invite-form.tsx new file mode 100644 index 00000000..4f5c9ff9 --- /dev/null +++ b/app/(app)/dashboard/surveys/[id]/send-invite-form.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { sendSurveyInvitation } from "@/app/actions/surveys"; + +export function SurveyInviteForm({ slug }: { slug: string }) { + const [pending, start] = useTransition(); + const [email, setEmail] = useState(""); + const [phone, setPhone] = useState(""); + const [emailConsent, setEmailConsent] = useState(false); + const [smsConsent, setSmsConsent] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(false); + + function submit(event: React.FormEvent) { + event.preventDefault(); + setMessage(""); + start(async () => { + const result = await sendSurveyInvitation({ slug, email, phone, emailConsent, smsConsent }); + setError(!result.ok); + if (!result.ok) { setMessage(result.error); return; } + if (result.channel === "none") return; + setMessage(`Invitation sent by ${result.channel}.`); + setEmail(""); setPhone(""); setEmailConsent(false); setSmsConsent(false); + }); + } + + return
+
+ + +
+
+

Confirm the recipient agreed to each channel you use.

+ + +
+ {message &&

{message}

} + +

One invitation costs 1 credit. A failed send is refunded. SMS includes STOP and HELP instructions.

+
; +} diff --git a/app/(app)/dashboard/surveys/[id]/status-button.tsx b/app/(app)/dashboard/surveys/[id]/status-button.tsx new file mode 100644 index 00000000..38035c6e --- /dev/null +++ b/app/(app)/dashboard/surveys/[id]/status-button.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { deleteSurvey, setSurveyStatus } from "@/app/actions/surveys"; + +export function SurveyStatusButton({ surveyId, status }: { surveyId: string; status: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(""); + const next = status === "live" ? "paused" : "live"; + return
+ + + {error &&

{error}

} +
; +} diff --git a/app/(app)/dashboard/surveys/new/form.tsx b/app/(app)/dashboard/surveys/new/form.tsx new file mode 100644 index 00000000..1c65ee04 --- /dev/null +++ b/app/(app)/dashboard/surveys/new/form.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { createSurvey } from "@/app/actions/surveys"; + +export type SurveyProjectOption = { id: string; name: string; url: string; organization_id: string }; + +export function NewSurveyForm({ projects }: { projects: SurveyProjectOption[] }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(""); + const [projectId, setProjectId] = useState(projects[0]?.id ?? ""); + const [title, setTitle] = useState(""); + const [question, setQuestion] = useState(""); + const [answerType, setAnswerType] = useState<"rating" | "text">("rating"); + const [aSubject, setASubject] = useState("One quick question about your experience"); + const [aMessage, setAMessage] = useState("Would you share quick feedback about your experience?"); + const [bSubject, setBSubject] = useState("Help us improve"); + const [bMessage, setBMessage] = useState("Could you spare a minute to answer one question?"); + + function submit(event: React.FormEvent) { + event.preventDefault(); + setError(""); + const project = projects.find((item) => item.id === projectId); + if (!project) { setError("Choose a site."); return; } + startTransition(async () => { + const result = await createSurvey({ + organizationId: project.organization_id, + projectId, + title, + question, + answerType, + variants: [ + { subject: aSubject, message: aMessage }, + { subject: bSubject, message: bMessage }, + ], + }); + if (!result.ok) { setError(result.error); return; } + router.push(`/dashboard/surveys/${result.id}`); + router.refresh(); + }); + } + + return ( +
+
+

Survey

+ + +