Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions app/(app)/dashboard/surveys/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl space-y-8">
<div>
<Link href="/dashboard/surveys" className="text-sm text-[var(--color-muted)]">← Surveys</Link>
<div className="mt-3 flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-3xl font-bold">{survey.title}</h1>
<span className={`badge ${survey.status === "live" ? "badge-pass" : ""}`}>{survey.status}</span>
</div>
<p className="mt-2 text-sm text-[var(--color-muted)]">{project?.name ?? "Site"} · {project?.url ?? ""}</p>
<p className="mt-4 text-lg">{survey.question}</p>
</div>
<SurveyStatusButton surveyId={survey.id} status={survey.status} />
</div>
</div>

<section className="card space-y-3 p-5">
<div>
<h2 className="text-lg font-semibold">Opt-in link</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">Share this link where people can choose email, SMS, or both. Each channel requires a separate opt-in.</p>
</div>
<a href={inviteUrl} target="_blank" rel="noreferrer" className="block break-all rounded border border-[var(--color-border)] p-3 font-mono text-sm text-[var(--color-accent)] underline">{inviteUrl}</a>
</section>

<section className="card p-5">
<h2 className="text-lg font-semibold">Send one invitation</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">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.</p>
<SurveyInviteForm slug={survey.slug} />
</section>

<section className="card overflow-hidden">
<div className="flex flex-wrap items-end justify-between gap-3 p-5">
<div>
<h2 className="text-lg font-semibold">Invitation A/B test</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">Stable recipient assignment · question stays the same between variants</p>
</div>
<p className="text-sm text-[var(--color-muted)]">{responses.length} response{responses.length === 1 ? "" : "s"} · {invites.length} invitation{invites.length === 1 ? "" : "s"}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-[var(--color-bg)] text-xs uppercase tracking-wider text-[var(--color-muted)]">
<tr><th className="px-5 py-3">Variant</th><th className="px-3 py-3 text-right">Invited</th><th className="px-3 py-3 text-right">Delivered</th><th className="px-3 py-3 text-right">Responses</th><th className="px-5 py-3 text-right">Response rate</th></tr>
</thead>
<tbody>
{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 (
<tr key={variant.key} className="border-t border-[var(--color-border)] align-top">
<td className="max-w-md px-5 py-4"><strong>{variant.key}</strong><p className="mt-1 text-xs text-[var(--color-muted)]">{variant.message}</p></td>
<td className="px-3 py-4 text-right tabular-nums">{assigned.length}</td>
<td className="px-3 py-4 text-right tabular-nums">{delivered}</td>
<td className="px-3 py-4 text-right tabular-nums">{answered}</td>
<td className="px-5 py-4 text-right tabular-nums">{rate}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>

<section className="card overflow-hidden">
<div className="p-5"><h2 className="text-lg font-semibold">Responses</h2><p className="mt-1 text-sm text-[var(--color-muted)]">Contact details are not shown in the response list.</p></div>
{responses.length === 0 ? <p className="px-5 pb-5 text-sm text-[var(--color-muted)]">No responses yet. Share the opt-in link or send an invitation.</p> : (
<div className="divide-y divide-[var(--color-border)]">
{responses.map((response) => {
const invite = invites.find((row) => row.id === response.invite_id);
return <div key={response.id} className="flex flex-wrap items-start justify-between gap-3 px-5 py-4">
<div><span className="badge">Variant {response.variant_key}</span><p className="mt-2 whitespace-pre-wrap text-sm">{response.rating != null ? `${response.rating} / 5` : response.answer_text}</p></div>
<time className="text-xs text-[var(--color-muted)]">{new Date(response.created_at).toLocaleString()}</time>
{invite?.delivery_channel && <span className="text-xs text-[var(--color-muted)]">via {invite.delivery_channel}</span>}
</div>;
})}
</div>
)}
</section>

<section className="card overflow-hidden">
<div className="p-5"><h2 className="text-lg font-semibold">Invitation history</h2></div>
{invites.length === 0 ? <p className="px-5 pb-5 text-sm text-[var(--color-muted)]">No invitations sent yet.</p> : (
<div className="overflow-x-auto"><table className="w-full text-left text-sm">
<thead className="bg-[var(--color-bg)] text-xs uppercase tracking-wider text-[var(--color-muted)]"><tr><th className="px-5 py-3">Recipient</th><th className="px-3 py-3">Variant</th><th className="px-3 py-3">Channel</th><th className="px-3 py-3">Status</th><th className="px-5 py-3">Sent</th></tr></thead>
<tbody>{invites.map((invite) => <tr key={invite.id} className="border-t border-[var(--color-border)]">
<td className="px-5 py-3">{invite.sms_consent && invite.email_consent ? "SMS + email fallback" : invite.sms_consent ? "SMS opt-in" : "Email opt-in"}</td>
<td className="px-3 py-3">{invite.variant_key}</td><td className="px-3 py-3 capitalize">{invite.delivery_channel ?? invite.requested_channel}</td>
<td className="px-3 py-3"><span className="badge">{invite.delivery_status}</span>{invite.delivery_error && <p className="mt-1 max-w-xs text-xs text-red-600">{invite.delivery_error}</p>}</td>
<td className="px-5 py-3 text-xs text-[var(--color-muted)]">{new Date(invite.created_at).toLocaleString()}</td>
</tr>)}</tbody>
</table></div>
)}
</section>
</div>
);
}
46 changes: 46 additions & 0 deletions app/(app)/dashboard/surveys/[id]/send-invite-form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
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 <form onSubmit={submit} className="mt-4 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block text-sm font-medium">Email
<input type="email" autoComplete="email" maxLength={320} value={email} onChange={(event) => setEmail(event.target.value)} className="input mt-1 w-full" placeholder="name@example.com" />
</label>
<label className="block text-sm font-medium">Phone
<input type="tel" autoComplete="tel" maxLength={24} value={phone} onChange={(event) => setPhone(event.target.value)} className="input mt-1 w-full" placeholder="+1 555 010 0123" />
</label>
</div>
<div className="space-y-2 rounded border border-[var(--color-border)] p-3 text-sm">
<p className="font-medium">Confirm the recipient agreed to each channel you use.</p>
<label className="flex items-start gap-2"><input type="checkbox" checked={smsConsent} onChange={(event) => setSmsConsent(event.target.checked)} className="mt-1" /><span>They agreed to one survey text from the site shown above at this number. Message and data rates may apply. Reply STOP to opt out or HELP for help.</span></label>
<label className="flex items-start gap-2"><input type="checkbox" checked={emailConsent} onChange={(event) => setEmailConsent(event.target.checked)} className="mt-1" /><span>They agreed to receive this survey by email. Email is used as a fallback only if SMS fails.</span></label>
</div>
{message && <p role={error ? "alert" : "status"} className={`text-sm ${error ? "text-red-600" : "text-green-700"}`}>{message}</p>}
<button className="btn btn-primary" disabled={pending}>{pending ? "Sending…" : "Send invitation"}</button>
<p className="text-xs text-[var(--color-muted)]">One invitation costs 1 credit. A failed send is refunded. SMS includes STOP and HELP instructions.</p>
</form>;
}
31 changes: 31 additions & 0 deletions app/(app)/dashboard/surveys/[id]/status-button.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="flex flex-wrap items-center justify-end gap-2 text-right">
<button className="btn" disabled={pending} onClick={() => startTransition(async () => {
setError("");
const result = await setSurveyStatus({ surveyId, status: next });
if (!result.ok) { setError(result.error); return; }
router.refresh();
})}>{pending ? "Saving…" : next === "paused" ? "Pause survey" : "Resume survey"}</button>
<button className="btn" disabled={pending} onClick={() => {
if (!window.confirm("Delete this survey, its invitations and all responses? This cannot be undone.")) return;
startTransition(async () => {
setError("");
const result = await deleteSurvey({ surveyId });
if (!result.ok) { setError(result.error); return; }
router.push("/dashboard/surveys");
router.refresh();
});
}}>Delete</button>
{error && <p role="alert" className="mt-2 text-xs text-red-600">{error}</p>}
</div>;
}
102 changes: 102 additions & 0 deletions app/(app)/dashboard/surveys/new/form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
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 (
<form onSubmit={submit} className="space-y-6">
<section className="card space-y-4 p-5">
<h2 className="text-lg font-semibold">Survey</h2>
<label className="block text-sm font-medium">Site
<select value={projectId} onChange={(event) => setProjectId(event.target.value)} className="input mt-1 block w-full">
{projects.map((project) => <option key={project.id} value={project.id}>{project.name} · {project.url}</option>)}
</select>
</label>
<label className="block text-sm font-medium">Title
<input value={title} onChange={(event) => setTitle(event.target.value)} required maxLength={100} className="input mt-1 block w-full" placeholder="Post-purchase feedback" />
</label>
<label className="block text-sm font-medium">Question
<textarea value={question} onChange={(event) => setQuestion(event.target.value)} required maxLength={500} rows={3} className="input mt-1 block w-full" placeholder="How easy was it to get started?" />
</label>
<label className="block text-sm font-medium">Answer format
<select value={answerType} onChange={(event) => setAnswerType(event.target.value as "rating" | "text")} className="input mt-1 block w-full sm:w-64">
<option value="rating">Rating from 1 to 5</option>
<option value="text">Written response</option>
</select>
</label>
</section>

<section className="card space-y-4 p-5">
<div>
<h2 className="text-lg font-semibold">Invitation A/B test</h2>
<p className="mt-1 text-sm text-[var(--color-muted)]">Each recipient is assigned one version consistently. The survey question stays the same in both groups.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<VariantFields label="Variant A" subject={aSubject} message={aMessage} onSubject={setASubject} onMessage={setAMessage} />
<VariantFields label="Variant B" subject={bSubject} message={bMessage} onSubject={setBSubject} onMessage={setBMessage} />
</div>
</section>
{error && <p role="alert" className="text-sm text-red-600">{error}</p>}
<button disabled={pending} className="btn btn-primary">{pending ? "Creating…" : "Create survey"}</button>
</form>
);
}

function VariantFields({ label, subject, message, onSubject, onMessage }: {
label: string;
subject: string;
message: string;
onSubject: (value: string) => void;
onMessage: (value: string) => void;
}) {
return (
<fieldset className="space-y-3 rounded border border-[var(--color-border)] p-4">
<legend className="px-1 text-sm font-semibold">{label}</legend>
<label className="block text-xs font-medium">Email subject
<input value={subject} onChange={(event) => onSubject(event.target.value)} maxLength={120} className="input mt-1 block w-full" />
</label>
<label className="block text-xs font-medium">Invitation message
<textarea value={message} onChange={(event) => onMessage(event.target.value)} required maxLength={240} rows={3} className="input mt-1 block w-full" />
</label>
</fieldset>
);
}
Loading