diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ed5f683..27ab765 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -23,48 +23,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v4
-
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: pnpm
-
- - name: Install
- run: pnpm install --frozen-lockfile
-
- - name: Build
- run: pnpm build
-
- - name: Stage release bundle
+ - name: Create release zip
run: |
- set -eux
- mkdir -p _release/web _release/api _release/db
-
- # Web: Next.js standalone is self-contained; just copy static + public next to it.
- cp -r apps/web/.next/standalone/. _release/web/
- mkdir -p _release/web/apps/web/.next
- cp -r apps/web/.next/static _release/web/apps/web/.next/static
- cp -r apps/web/public _release/web/apps/web/public
-
- # API: pnpm deploy produces a self-contained dir (dist + node_modules + package.json)
- pnpm --filter @repo/api deploy --prod _release/api
- # DB: same trick, plus the drizzle SQL + migrate.mjs.
- pnpm --filter @repo/database deploy --prod _release/db
- cp -r packages/database/drizzle _release/db/drizzle
- cp packages/database/migrate.mjs _release/db/migrate.mjs
-
- cp ecosystem.config.cjs _release/ecosystem.config.cjs
+ zip -r release.zip . -x "node_modules/*" ".git/*" ".next/*" "apps/web/.next/*" "apps/api/dist/*" "stress-tests/*"
- name: Write .env from secret
env:
PROD_ENV: ${{ secrets.PROD_ENV }}
run: |
- # File is world-readable on the runner so the scp-action's
- # Docker container can read it. It only lives here for ~10s
- # before the runner is destroyed. Once it lands on the VM
- # the next step chmod's it back to 600.
- printf '%s\n' "$PROD_ENV" > _release/.env
+ printf '%s\n' "$PROD_ENV" > _env
- name: Copy files to VM
uses: appleboy/scp-action@v0.1.7
@@ -73,12 +40,10 @@ jobs:
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: ${{ secrets.SSH_PORT }}
- source: "_release/*,_release/.env"
+ source: "release.zip,_env"
target: "/home/dittya/projects/canvasflow"
- strip_components: 1
- overwrite: true
- - name: Migrate + reload PM2
+ - name: Deploy via Docker Compose
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
@@ -88,30 +53,22 @@ jobs:
script: |
set -eu
cd /home/dittya/projects/canvasflow
+
+ # Extract release bundle
+ unzip -o release.zip
+ rm release.zip
+ mv _env .env
chmod 600 .env
- # dotenv reads .env from cwd in each process — symlink so
- # both api and web (and the migrator) all find it.
- ln -sf "$PWD/.env" api/.env
- ln -sf "$PWD/.env" web/apps/web/.env
- ln -sf "$PWD/.env" db/.env
-
- # Apply pending DB migrations. If this fails, pm2 reload is
- # never reached and the running app keeps serving the old code.
- set -a; . ./.env; set +a
- (cd db && node migrate.mjs)
+ # Build and deploy Docker containers
+ docker compose -f docker-compose.prod.yml up --build -d
- # Start or reload PM2.
- if pm2 describe canvasflow-api >/dev/null 2>&1; then
- pm2 reload ecosystem.config.cjs --update-env
- else
- pm2 start ecosystem.config.cjs
- pm2 save
- fi
+ # Cleanup builder artifacts and dangling cache
+ docker image prune -f
- name: Summary
if: success()
run: |
- echo "### ✅ Deployed from \`${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY"
+ echo "### ✅ Deployed from \`${{ github.sha }}\` via Docker Compose" >> "$GITHUB_STEP_SUMMARY"
echo "- web: https://canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY"
echo "- api: https://api.canvasflow.dittya.dev" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index f62cd8b..13cad58 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,4 +37,5 @@ yarn-error.log*
.DS_Store
*.pem
-.kiro
\ No newline at end of file
+.kiro
+stress-tests/
\ No newline at end of file
diff --git a/apps/api/src/lib/rate-limit-store.ts b/apps/api/src/lib/rate-limit-store.ts
deleted file mode 100644
index 360dfe0..0000000
--- a/apps/api/src/lib/rate-limit-store.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import RedisStore from "rate-limit-redis";
-import type { Store } from "express-rate-limit";
-import { isRedisConfigured, redisKey, redisReady } from "@repo/redis";
-
-export function redisRateLimitStore(bucket: string): Store | undefined {
- if (!isRedisConfigured()) return undefined;
-
- return new RedisStore({
- sendCommand: async (...args: string[]) => {
- const connection = await redisReady();
- if (!connection) throw new Error("Redis unavailable for rate limiting");
-
- return (connection as unknown as { call: (...a: string[]) => Promise }).call(...args);
- },
-
- prefix: `${redisKey("rl", bucket)}:`,
- }) as unknown as Store;
-}
diff --git a/apps/api/src/lib/rate-limiter.ts b/apps/api/src/lib/rate-limiter.ts
new file mode 100644
index 0000000..cd5e744
--- /dev/null
+++ b/apps/api/src/lib/rate-limiter.ts
@@ -0,0 +1,107 @@
+import type { Request, Response, NextFunction } from "express";
+import { randomUUID } from "node:crypto";
+import { isRedisConfigured, redisKey, redisReady } from "@repo/redis";
+
+interface RateLimiterOptions {
+ bucketName: string;
+ max: number; // Bucket capacity (max burst size)
+ windowMs: number; // Time window in milliseconds (leak duration)
+ message?: string | object;
+}
+
+const gcraScript = `
+local key = KEYS[1]
+local capacity = tonumber(ARGV[1])
+local window_ms = tonumber(ARGV[2])
+local now = tonumber(ARGV[3])
+
+local emission_interval = window_ms / capacity
+local limit = capacity * emission_interval
+
+local tat = tonumber(redis.call('GET', key))
+
+if not tat then
+ tat = now
+else
+ tat = math.max(tat, now)
+end
+
+local tat_diff = tat - now
+
+if tat_diff > limit then
+ return {0, math.ceil(tat_diff - limit)}
+else
+ local new_tat = tat + emission_interval
+ redis.call('SET', key, new_tat, 'PX', math.ceil(new_tat - now))
+ return {1, 0}
+end
+`;
+
+export function leakyBucketRateLimiter(opts: RateLimiterOptions) {
+ const { bucketName, max, windowMs, message } = opts;
+
+ return async (req: Request, res: Response, next: NextFunction) => {
+ if (!isRedisConfigured()) {
+ return next();
+ }
+
+ try {
+ const client = await redisReady();
+ if (!client) {
+ return next();
+ }
+
+ // 1. Identify client
+ let clientKey = "";
+
+ // Try resolving auth session token
+ const sessionToken = req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1];
+ if (sessionToken) {
+ clientKey = `session:${sessionToken}`;
+ } else {
+ // Fallback to visitor cookie
+ let visitorId = req.cookies?.["cf_visitor_id"] as string | undefined;
+ if (!visitorId) {
+ visitorId = randomUUID();
+ res.cookie("cf_visitor_id", visitorId, {
+ maxAge: 365 * 24 * 60 * 60 * 1000, // 1 year
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/",
+ });
+ }
+ clientKey = `visitor:${visitorId}`;
+ }
+
+ const key = `${redisKey("rl", bucketName)}:${clientKey}`;
+ const now = Date.now();
+
+ // Execute atomic GCRA script
+ const result = await client.eval(
+ gcraScript,
+ 1,
+ key,
+ max.toString(),
+ windowMs.toString(),
+ now.toString()
+ ) as [number, number];
+
+ const [allowed, retryAfterMs] = result;
+
+ if (allowed === 1) {
+ res.setHeader("X-RateLimit-Limit", max);
+ return next();
+ } else {
+ const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
+ res.setHeader("Retry-After", retryAfterSec);
+ res.setHeader("X-RateLimit-Limit", max);
+
+ const errorMsg = message || { error: "Too many requests — slow down and try again shortly." };
+ return res.status(429).json(typeof errorMsg === "string" ? { error: errorMsg } : errorMsg);
+ }
+ } catch (err) {
+ console.error("[rate-limiter] error executing leaky bucket:", err);
+ return next();
+ }
+ };
+}
diff --git a/apps/api/src/routes/upload.ts b/apps/api/src/routes/upload.ts
index 2e7c680..fe04d7d 100644
--- a/apps/api/src/routes/upload.ts
+++ b/apps/api/src/routes/upload.ts
@@ -6,14 +6,14 @@ import path from "node:path";
import express, { type NextFunction, type Request, type Response } from "express";
import multer, { MulterError } from "multer";
-import rateLimit, { ipKeyGenerator } from "express-rate-limit";
+
import { logger } from "@repo/logger";
import { enqueueUpload, isQueueAvailable } from "@repo/queue";
import { storageKindFor } from "@repo/services/form-upload";
import { formUploadService } from "@repo/trpc/server/services";
import { env } from "../env";
-import { redisRateLimitStore } from "../lib/rate-limit-store";
+import { leakyBucketRateLimiter } from "../lib/rate-limiter";
export const uploadRouter: express.Router = express.Router();
@@ -107,13 +107,10 @@ const upload = multer({
},
});
-const uploadLimiter = rateLimit({
- windowMs: 60_000,
+const uploadLimiter = leakyBucketRateLimiter({
+ bucketName: "upload",
max: env.RATE_LIMIT_UPLOAD_MAX,
- standardHeaders: "draft-7",
- legacyHeaders: false,
- keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"),
- store: redisRateLimitStore("upload"),
+ windowMs: 60_000,
message: { error: "Too many uploads — wait a minute and try again." },
});
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 58dd6d1..a8f9f04 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -2,7 +2,7 @@ import express from "express";
import { logger } from "@repo/logger";
import cors from "cors";
import compression from "compression";
-import rateLimit, { ipKeyGenerator } from "express-rate-limit";
+
import * as trpcExpress from "@trpc/server/adapters/express";
import { generateOpenApiDocument, createOpenApiExpressMiddleware } from "trpc-to-openapi";
@@ -14,7 +14,7 @@ import { toNodeHandler } from "better-auth/node";
import { env } from "./env";
import { uploadRouter, uploadErrorHandler } from "./routes/upload";
-import { redisRateLimitStore } from "./lib/rate-limit-store";
+import { leakyBucketRateLimiter } from "./lib/rate-limiter";
export const app = express();
@@ -75,29 +75,22 @@ app.use(
app.use(cookieParser());
-const publicWriteLimiter = rateLimit({
- windowMs: 60_000,
+const publicWriteLimiter = leakyBucketRateLimiter({
+ bucketName: "public-write",
max: env.RATE_LIMIT_PUBLIC_WRITE_MAX,
- standardHeaders: "draft-7",
- legacyHeaders: false,
- store: redisRateLimitStore("public-write"),
+ windowMs: 60_000,
message: { error: "Too many requests — slow down and try again in a minute." },
});
-const authGlobalLimiter = rateLimit({
- windowMs: 60_000,
+const authGlobalLimiter = leakyBucketRateLimiter({
+ bucketName: "auth-global",
max: env.RATE_LIMIT_AUTH_MAX,
- standardHeaders: "draft-7",
- legacyHeaders: false,
- keyGenerator: (req) =>
- req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1] ??
- ipKeyGenerator(req.ip ?? "unknown"),
- store: redisRateLimitStore("auth-global"),
+ windowMs: 60_000,
message: { error: "Request rate exceeded for this session." },
});
app.use(
- ["/trpc/analytics.recordFieldAnswer", "/trpc/form.submitForm", "/trpc/feedback.submitFeedback"],
+ ["/trpc/form.submitForm", "/trpc/feedback.submitFeedback"],
publicWriteLimiter,
);
diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts
index ed6c050..ddaa732 100644
--- a/apps/api/tsup.config.ts
+++ b/apps/api/tsup.config.ts
@@ -13,6 +13,6 @@ export default defineConfig({
minify: false,
sourcemap: false,
banner: {
- js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url);`,
+ js: `import { createRequire as __createRequire } from 'module'; const require = __createRequire(import.meta.url);`,
},
});
diff --git a/apps/web/app/about/page.tsx b/apps/web/app/about/page.tsx
index e456465..748c29a 100644
--- a/apps/web/app/about/page.tsx
+++ b/apps/web/app/about/page.tsx
@@ -205,8 +205,8 @@ export default function AboutPage() {
dropped off, and every response in a table you can export — no spreadsheets to stitch
together first.
-
- Explore analytics →
+
+ Explore responses →
diff --git a/apps/web/app/dashboard/analytics/page.tsx b/apps/web/app/dashboard/analytics/page.tsx
deleted file mode 100644
index 75323d6..0000000
--- a/apps/web/app/dashboard/analytics/page.tsx
+++ /dev/null
@@ -1,429 +0,0 @@
-"use client";
-
-import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
-import dynamic from "next/dynamic";
-import Link from "next/link";
-import { useRouter, useSearchParams } from "next/navigation";
-import { Download } from "lucide-react";
-import { toast } from "sonner";
-
-import { useListFormsByUserId, useGetFormById } from "~/hooks/api/form";
-import {
- useGetFormAnalytics,
- useGetDetailedAnalytics,
- useGetSubmissions,
-} from "~/hooks/api/analytics";
-import { useDebounce } from "~/hooks/useDebounce";
-
-import { AnalyticsFormPicker } from "~/components/analytics/AnalyticsFormPicker";
-import { MetricsGrid } from "~/components/analytics/MetricsGrid";
-import { StatsRow } from "~/components/analytics/StatsRow";
-import { SubmissionsTable } from "~/components/analytics/SubmissionsTable";
-import { DEVICE_COLORS } from "~/components/analytics/palette";
-
-const ResponseTimeline = dynamic(
- () => import("~/components/analytics/ResponseTimeline").then((m) => m.ResponseTimeline),
- { ssr: false },
-);
-const DeviceBreakdown = dynamic(
- () => import("~/components/analytics/DeviceBreakdown").then((m) => m.DeviceBreakdown),
- { ssr: false },
-);
-const QuestionDistribution = dynamic(
- () => import("~/components/analytics/QuestionDistribution").then((m) => m.QuestionDistribution),
- { ssr: false },
-);
-const TrafficSources = dynamic(
- () => import("~/components/analytics/TrafficSources").then((m) => m.TrafficSources),
- { ssr: false },
-);
-const FieldDropoff = dynamic(
- () => import("~/components/analytics/FieldDropoff").then((m) => m.FieldDropoff),
- { ssr: false },
-);
-const PeriodComparison = dynamic(
- () => import("~/components/analytics/PeriodComparison").then((m) => m.PeriodComparison),
- { ssr: false },
-);
-const EngagementStats = dynamic(
- () => import("~/components/analytics/EngagementStats").then((m) => m.EngagementStats),
- { ssr: false },
-);
-
-type TabId = "summary" | "responses" | "dropoff" | "segments";
-
-const TABS: { id: TabId; label: string }[] = [
- { id: "summary", label: "Summary" },
- { id: "responses", label: "Responses" },
- { id: "dropoff", label: "Drop-off" },
- { id: "segments", label: "Segments" },
-];
-
-interface SubmissionValue {
- formFieldId: string;
- value: any;
-}
-
-interface Submission {
- id: string;
- formId: string;
- values: SubmissionValue[];
- createdAt: string;
-}
-
-export function AnalyticsPage() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const [searchQuery, setSearchQuery] = useState("");
- // Debounce so filtering and any future expensive operations on the
- // submissions list don't run on every keystroke.
- const debouncedSearchQuery = useDebounce(searchQuery, 200);
- const [viewingSubmission, setViewingSubmission] = useState(null);
-
- const [tab, setTab] = useState("summary");
-
- const selectedFormId = searchParams.get("form");
-
- const setSelectedFormId = useCallback(
- (id: string) => {
- router.replace(`/dashboard/analytics?form=${id}`, { scroll: false });
- },
- [router],
- );
-
- const { forms, isLoading: isLoadingForms } = useListFormsByUserId();
- const { form, isLoading: isLoadingForm } = useGetFormById(selectedFormId || "");
- const { analytics, isLoading: isLoadingAnalytics } = useGetFormAnalytics(selectedFormId || "");
- const {
- submissions,
- isLoading: isLoadingSubmissions,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
- } = useGetSubmissions(selectedFormId || "");
- const { detailedAnalytics } = useGetDetailedAnalytics(selectedFormId || "");
-
- useEffect(() => {
- if (forms && forms.length > 0 && !selectedFormId) {
- const firstForm = forms[0];
- if (firstForm) setSelectedFormId(firstForm.id);
- }
- }, [forms, selectedFormId, setSelectedFormId]);
-
- const getRespondentDetails = useCallback(
- (sub: Submission) => {
- let name = "Anonymous";
- let email = "no-email@anonymous.com";
-
- if (form?.fields) {
- const emailField = form.fields.find(
- (f) => f.type === "EMAIL" || f.label.toLowerCase().includes("email"),
- );
- if (emailField) {
- const val = sub.values.find((v) => v.formFieldId === emailField.id);
- if (val?.value) email = String(val.value);
- }
-
- const nameField = form.fields.find(
- (f) =>
- f.type === "TEXT" &&
- (f.label.toLowerCase().includes("name") ||
- f.label.toLowerCase().includes("respondent")),
- );
- if (nameField) {
- const val = sub.values.find((v) => v.formFieldId === nameField.id);
- if (val?.value) name = String(val.value);
- } else if (email && email !== "no-email@anonymous.com") {
- name = email.split("@")[0] || "Anonymous";
- }
- }
- return { name, email };
- },
- [form],
- );
-
- const handleExportCSV = () => {
- if (!form || !submissions || submissions.length === 0) {
- toast.error("No submissions available to export");
- return;
- }
-
- const headers = ["Submission ID", "Submitted At"];
- form.fields.forEach((f) => headers.push(f.label));
- const csvRows = [headers.join(",")];
-
- submissions.forEach((sub) => {
- const row = [sub.id, new Date(sub.createdAt).toLocaleString()];
- form.fields.forEach((f) => {
- const answer = sub.values.find((v) => v.formFieldId === f.id);
- let valStr = "";
- if (answer?.value !== undefined && answer?.value !== null) {
- if (Array.isArray(answer.value)) {
- valStr = `"${answer.value.join("; ")}"`;
- } else {
- valStr = `"${String(answer.value).replace(/"/g, '""')}"`;
- }
- }
- row.push(valStr);
- });
- csvRows.push(row.join(","));
- });
-
- const blob = new Blob([csvRows.join("\n")], {
- type: "text/csv;charset=utf-8;",
- });
- const url = URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.setAttribute("href", url);
- link.setAttribute(
- "download",
- `${form.title.toLowerCase().replace(/\s+/g, "_")}_submissions.csv`,
- );
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- toast.success("CSV downloaded");
- };
-
- // Derived metrics
- const totalResponses = analytics?.totalResponses ?? 0;
- const avgPerDay = analytics?.avgSubmissionsPerDay ?? 0;
- const avgPerWeek = analytics?.avgSubmissionsPerWeek ?? 0;
- const peakDay = analytics?.peakDay ?? null;
-
- const deviceData = useMemo(() => {
- const deviceBreakdown = analytics?.deviceBreakdown ?? [];
- const desktop = deviceBreakdown.find((d) => d.device === "desktop")?.count ?? 0;
- const mobile = deviceBreakdown.find((d) => d.device === "mobile")?.count ?? 0;
- const tablet = deviceBreakdown.find((d) => d.device === "tablet")?.count ?? 0;
-
- return [
- { name: "Desktop", value: desktop, color: DEVICE_COLORS.Desktop },
- { name: "Mobile", value: mobile, color: DEVICE_COLORS.Mobile },
- { name: "Tablet", value: tablet, color: DEVICE_COLORS.Tablet },
- ];
- }, [analytics]);
-
- const dailyTrends = analytics?.dailyTrends ?? [];
-
- const filteredSubmissions = useMemo(() => {
- const matchQuery = debouncedSearchQuery.toLowerCase();
- return submissions.filter((sub) => {
- const details = getRespondentDetails(sub);
- return (
- details.name.toLowerCase().includes(matchQuery) ||
- details.email.toLowerCase().includes(matchQuery) ||
- sub.id.toLowerCase().includes(matchQuery)
- );
- });
- }, [submissions, debouncedSearchQuery, getRespondentDetails]);
-
- const isLoading = isLoadingForm || isLoadingAnalytics || isLoadingSubmissions;
-
- return (
-
-
-
- Analytics
- .
-
-
- Read the numbers behind every form you publish.
-
-
-
-
-
-
- {isLoading ? (
-
- ) : !form ? (
-
-
-
No form selected
-
Pick a form
-
- Choose a form from the sidebar to load its analytics and response history.
-
-
-
- ) : (
-
- {/* chrome bar */}
-
-
CanvasFlow · {form.title}
-
-
- Edit
-
-
-
- Export
-
-
-
-
-
-
- {form.title} · Overview
-
-
- Live breakdown of responses across time, devices, and questions.
-
-
-
- {TABS.map((t) => (
- setTab(t.id)}
- className="cf-tab shrink-0"
- >
- {t.label}
-
- ))}
-
-
-
-
-
-
-
- {tab === "summary" && (
-
-
-
-
-
-
- {/* Waits on the detailed query rather than gating on a
- plan — the trend buckets simply aren't loaded yet. */}
- {detailedAnalytics && (
-
- )}
-
- )}
-
- {tab === "responses" && (
-
- )}
-
- {tab === "dropoff" && (
-
-
-
-
- )}
-
- {tab === "segments" && (
-
-
-
-
-
-
-
-
-
- )}
-
-
-
- )}
-
-
- );
-}
-
-function SegmentGroup({
- label,
- hint,
- children,
-}: {
- label: string;
- hint: string;
- children: React.ReactNode;
-}) {
- return (
-
- );
-}
-
-export default function AnalyticsPageWrapper() {
- return (
-
-
-
- );
-}
diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx
index 6e8ac6c..5674291 100644
--- a/apps/web/app/dashboard/page.tsx
+++ b/apps/web/app/dashboard/page.tsx
@@ -143,7 +143,7 @@ export default function DashboardPage() {
-
Analytics
+
Responses
Response trends
diff --git a/apps/web/app/dashboard/sketches/[formId]/page.tsx b/apps/web/app/dashboard/sketches/[formId]/page.tsx
index 4cb3722..15616bb 100644
--- a/apps/web/app/dashboard/sketches/[formId]/page.tsx
+++ b/apps/web/app/dashboard/sketches/[formId]/page.tsx
@@ -1,7 +1,7 @@
"use client";
import React from "react";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Background, BackgroundVariant, Panel, ReactFlow, ReactFlowProvider } from "@xyflow/react";
import "@xyflow/react/dist/style.css";
@@ -22,6 +22,7 @@ import { MobileAddFieldSheet } from "~/components/builder/mobile/MobileAddFieldS
import { MobileFieldEditorSheet } from "~/components/builder/mobile/MobileFieldEditorSheet";
import { ShareCollaboratorsDialog } from "~/components/builder/ShareCollaboratorsDialog";
import { FormSettingsDialog } from "~/components/builder/FormSettingsDialog";
+import { ResponsesView } from "~/components/builder/ResponsesView";
import { useBuilderState } from "~/components/builder/useBuilderState";
function BuilderCanvas() {
@@ -125,6 +126,10 @@ function BuilderCanvas() {
deleteFormAsync,
} = useBuilderState();
+ const searchParams = useSearchParams();
+ const activeTabParam = searchParams?.get("tab") || "questions";
+ const activeTab = (activeTabParam === "responses" || activeTabParam === "summary") ? "responses" : "questions";
+
if (formLoading || fieldsLoading) {
return (
@@ -167,15 +172,15 @@ function BuilderCanvas() {
You don't have access to edit this form
- You only have viewer access to “{form.title}”. You can view its submissions
- and analytics, but you cannot make changes to the fields.
+ You only have viewer access to “{form.title}”. You can view its submissions,
+ but you cannot make changes to the fields.
- View analytics
+ View responses
Back to studio
@@ -188,285 +193,300 @@ function BuilderCanvas() {
return (
-
{
- void refetchForm();
- }}
- onShare={() => setShowShareDialog(true)}
- onSettings={() => setShowSettingsDialog(true)}
- view={view}
- onViewChange={handleViewChange}
- />
-
-
-
-
-
-
updateSegmentLocal(id, { title })}
- onMoveSegment={handleMoveSegment}
- onDeleteSegment={handleDeleteSegment}
- />
-
-
-
-
+ {activeTab === "questions" && (
+
{
+ void refetchForm();
+ }}
+ onShare={() => setShowShareDialog(true)}
+ onSettings={() => setShowSettingsDialog(true)}
+ view={view}
+ onViewChange={handleViewChange}
+ />
+ )}
- {view === "outline" ? (
-
-
-
- ) : (
-
-
-
-
Canvas
-
- {visibleSortedFields.length}{" "}
- {visibleSortedFields.length === 1 ? "field" : "fields"}
-
+ {activeTab === "questions" ? (
+ <>
+
+
+
+
+
updateSegmentLocal(id, { title })}
+ onMoveSegment={handleMoveSegment}
+ onDeleteSegment={handleDeleteSegment}
+ />
+
+
-
- setIsLocked(!isLocked)}
- aria-pressed={isLocked}
- title={isLocked ? "Unlock canvas" : "Lock canvas"}
- className={`inline-flex h-5.5 shrink-0 cursor-pointer items-center gap-1.5 border px-2 font-mono text-[10px] tracking-wider uppercase transition-colors ${
- isLocked
- ? "border-(--cf-orange) text-(--cf-orange)"
- : "border-(--cf-line-strong) text-(--cf-ink-soft) hover:text-(--cf-ink)"
- }`}
- >
- {isLocked ? : }
- {isLocked ? "Locked" : "Unlocked"}
-
-
-
-
+
+ ) : (
+
+
+
+ Canvas
+
+ {visibleSortedFields.length}{" "}
+ {visibleSortedFields.length === 1 ? "field" : "fields"}
+
+
-
- zoomIn()}
- title="Zoom in"
- aria-label="Zoom in"
- className="size-7 rounded-md text-(--cf-ink) hover:bg-(--cf-cream) hover:text-(--cf-orange) flex items-center justify-center transition-colors cursor-pointer"
- >
-
-
zoomOut()}
- title="Zoom out"
- aria-label="Zoom out"
- className="size-7 rounded-md text-(--cf-ink) hover:bg-(--cf-cream) hover:text-(--cf-orange) flex items-center justify-center transition-colors cursor-pointer"
+ onClick={() => setIsLocked(!isLocked)}
+ aria-pressed={isLocked}
+ title={isLocked ? "Unlock canvas" : "Lock canvas"}
+ className={`inline-flex h-5.5 shrink-0 cursor-pointer items-center gap-1.5 border px-2 font-mono text-[10px] tracking-wider uppercase transition-colors ${isLocked
+ ? "border-(--cf-orange) text-(--cf-orange)"
+ : "border-(--cf-line-strong) text-(--cf-ink-soft) hover:text-(--cf-ink)"
+ }`}
>
-
+ {isLocked ? : }
+ {isLocked ? "Locked" : "Unlocked"}
- fitView({ duration: 400 })}
- title="Fit view"
- aria-label="Fit view"
- className="size-7 rounded-md text-(--cf-ink) hover:bg-(--cf-cream) hover:text-(--cf-orange) flex items-center justify-center transition-colors cursor-pointer"
+
+
+
+
-
-
-
-
-
-
- )}
+
- {
- if (!selectedField) return;
- updateLocal(selectedField.id, { segmentId });
- if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId);
- }}
- ruleSummaries={selectedFieldRuleSummaries}
- incompleteRuleCount={selectedFieldIncompleteRules}
- onEditBranching={
- selectedField ? () => setBranchingFieldId(selectedField.id) : undefined
- }
- isLastInSegment={isSelectedFieldLastInSegment}
- />
+
+ zoomIn()}
+ title="Zoom in"
+ aria-label="Zoom in"
+ className="size-7 rounded-md text-(--cf-ink) hover:bg-(--cf-cream) hover:text-(--cf-orange) flex items-center justify-center transition-colors cursor-pointer"
+ >
+
+
+ zoomOut()}
+ title="Zoom out"
+ aria-label="Zoom out"
+ className="size-7 rounded-md text-(--cf-ink) hover:bg-(--cf-cream) hover:text-(--cf-orange) flex items-center justify-center transition-colors cursor-pointer"
+ >
+
+
+ fitView({ duration: 400 })}
+ title="Fit view"
+ aria-label="Fit view"
+ className="size-7 rounded-md text-(--cf-ink) hover:bg-(--cf-cream) hover:text-(--cf-orange) flex items-center justify-center transition-colors cursor-pointer"
+ >
+
+
+
+
+
+
+ )}
-
-
+
{
+ if (!selectedField) return;
+ updateLocal(selectedField.id, { segmentId });
+ if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId);
+ }}
+ ruleSummaries={selectedFieldRuleSummaries}
+ incompleteRuleCount={selectedFieldIncompleteRules}
+ onEditBranching={
+ selectedField ? () => setBranchingFieldId(selectedField.id) : undefined
+ }
+ isLastInSegment={isSelectedFieldLastInSegment}
+ />
-
-
- setMobileSegmentsOpen((prev) => !prev)}
- aria-expanded={mobileSegmentsOpen}
- className="flex w-full items-center justify-between px-4 py-2.5 text-left"
- >
-
-
- Segments
-
- {visibleSegments.length || "none"}
-
-
-
- {selectedSegmentId && (
-
- {visibleSegments.find((s) => s.id === selectedSegmentId)?.title ?? "filtered"}
+
+
+
+
+
+ setMobileSegmentsOpen((prev) => !prev)}
+ aria-expanded={mobileSegmentsOpen}
+ className="flex w-full items-center justify-between px-4 py-2.5 text-left"
+ >
+
+
+ Segments
+
+ {visibleSegments.length || "none"}
+
+
+ {selectedSegmentId && (
+
+ {visibleSegments.find((s) => s.id === selectedSegmentId)?.title ?? "filtered"}
+
+ )}
+ {mobileSegmentsOpen ? (
+
+ ) : (
+
+ )}
+
+
+
+ {mobileSegmentsOpen && (
+ updateSegmentLocal(id, { title })}
+ onMoveSegment={handleMoveSegment}
+ onDeleteSegment={handleDeleteSegment}
+ />
)}
- {mobileSegmentsOpen ? (
-
- ) : (
-
- )}
-
-
+
- {mobileSegmentsOpen && (
-
updateSegmentLocal(id, { title })}
- onMoveSegment={handleMoveSegment}
- onDeleteSegment={handleDeleteSegment}
+ setMobileAddOpen(true)}
/>
- )}
+
- setMobileAddOpen(true)}
- />
-
-
-
-
-
setMobileAddOpen(false)}
- onSelect={handleMobileAddField}
- />
- {
- if (!selectedField) return;
- updateLocal(selectedField.id, { segmentId });
- if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId);
- }}
- ruleSummaries={selectedFieldRuleSummaries}
- incompleteRuleCount={selectedFieldIncompleteRules}
- onEditBranching={
- selectedField
- ? () => {
- handleCloseMobileEditor();
- setBranchingFieldId(selectedField.id);
- }
- : undefined
- }
- isLastInSegment={isSelectedFieldLastInSegment}
+
+ setMobileAddOpen(false)}
+ onSelect={handleMobileAddField}
+ />
+ {
+ if (!selectedField) return;
+ updateLocal(selectedField.id, { segmentId });
+ if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId);
+ }}
+ ruleSummaries={selectedFieldRuleSummaries}
+ incompleteRuleCount={selectedFieldIncompleteRules}
+ onEditBranching={
+ selectedField
+ ? () => {
+ handleCloseMobileEditor();
+ setBranchingFieldId(selectedField.id);
+ }
+ : undefined
+ }
+ isLastInSegment={isSelectedFieldLastInSegment}
+ />
+
+ >
+ ) : activeTab === "responses" ? (
+ router.replace(`/dashboard/sketches/${formId}?tab=${tab}`, { scroll: false })}
+ onShare={() => setShowShareDialog(true)}
/>
-
+ ) : null}
- Continue editing
+ Edit
)}
+
+ Responses
+
{
- if (isPreview) return;
- recordFieldAnswer({ formId, fieldId, value: stripUploadSecrets(value) });
- },
- [isPreview, formId, recordFieldAnswer],
- );
-
- const { run: queueTrackAnswer } = useDebouncedCallback(trackAnswer, 800);
-
const handleFieldChange = (fieldId: string, value: any) => {
setAnswers((prev) => {
const next = { ...prev, [fieldId]: value };
queueDraftSave(next, pagePath);
return next;
});
- queueTrackAnswer(fieldId, value);
};
const flow = useMemo(
@@ -415,19 +401,7 @@ export default function PublicFormPage() {
}
}
- if (!isPreview) {
- for (const field of currentFields) {
- const value = answers[field.id];
- const hasAnswer =
- value !== undefined &&
- value !== null &&
- value !== "" &&
- !(Array.isArray(value) && value.length === 0);
- if (hasAnswer || field.type === "TOGGLE") {
- recordFieldAnswer({ formId, fieldId: field.id, value: stripUploadSecrets(value) });
- }
- }
- }
+
if (nextPage.kind === "page") {
const advanced = [...pagePath, nextPage.pageIndex];
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index ee33610..f0170a8 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -309,8 +309,8 @@ const Index = () => {
and which question they gave up on. Your form becomes a real dashboard the second
answers land — no exports, no spreadsheets.
-
- Explore analytics →
+
+ Explore responses →
diff --git a/apps/web/components/DashboardNav.tsx b/apps/web/components/DashboardNav.tsx
index e6aa022..a2e3523 100644
--- a/apps/web/components/DashboardNav.tsx
+++ b/apps/web/components/DashboardNav.tsx
@@ -4,7 +4,7 @@ import React, { useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
-import { BarChart3, ChevronRight, Compass, Menu, PencilRuler, Plus, X } from "lucide-react";
+import { ChevronRight, Compass, Menu, PencilRuler, Plus, X } from "lucide-react";
import { useDashboard } from "~/providers/dashboard-provider";
import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
@@ -14,7 +14,6 @@ import { avatarSeed, GlyphAvatar, resolvePreset } from "~/components/profile/Gly
const LINKS = [
{ href: "/dashboard", label: "Studio", icon: Compass },
{ href: "/dashboard/sketches", label: "Forms", icon: PencilRuler },
- { href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 },
];
export default function DashboardNav() {
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
index 8644309..e29b1dc 100644
--- a/apps/web/components/Footer.tsx
+++ b/apps/web/components/Footer.tsx
@@ -65,12 +65,7 @@ const Footer = () => {
>
Your Forms
-
- Analytics
-
+
diff --git a/apps/web/components/NotFoundPanel.tsx b/apps/web/components/NotFoundPanel.tsx
index 702d8dc..4df3db3 100644
--- a/apps/web/components/NotFoundPanel.tsx
+++ b/apps/web/components/NotFoundPanel.tsx
@@ -210,7 +210,6 @@ export function NotFoundPanel() {
["Learn more", "/learn-more"],
["About", "/about"],
["Your forms", "/dashboard/sketches"],
- ["Analytics", "/dashboard/analytics"],
].map(([label, href]) => (
{label}
diff --git a/apps/web/components/analytics/AnalyticsFormPicker.tsx b/apps/web/components/analytics/AnalyticsFormPicker.tsx
deleted file mode 100644
index e0cac0e..0000000
--- a/apps/web/components/analytics/AnalyticsFormPicker.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-"use client";
-
-import React from "react";
-import { trpc } from "~/trpc/client";
-
-interface FormItem {
- id: string;
- title: string;
- isPublished: boolean;
-}
-
-interface AnalyticsFormPickerProps {
- isLoadingForms: boolean;
- forms: FormItem[] | undefined;
- selectedFormId: string | null;
- setSelectedFormId: (id: string) => void;
-}
-
-export function AnalyticsFormPicker({
- isLoadingForms,
- forms,
- selectedFormId,
- setSelectedFormId,
-}: AnalyticsFormPickerProps) {
- const utils = trpc.useUtils();
- const prefetchForm = (id: string) => {
- void utils.form.getFormById.prefetch({ id });
- void utils.analytics.getFormAnalytics.prefetch({ formId: id });
- void utils.analytics.getSubmissions.prefetchInfinite({ formId: id, limit: 100 });
- };
-
- return (
-
-
Form
-
- {isLoadingForms ? (
-
Loading
- ) : !forms || forms.length === 0 ? (
-
No forms yet
- ) : (
-
- {forms.map((f) => {
- const isActive = selectedFormId === f.id;
- return (
- setSelectedFormId(f.id)}
- onMouseEnter={() => prefetchForm(f.id)}
- onFocus={() => prefetchForm(f.id)}
- className="inline-flex max-w-[16rem] shrink-0 cursor-pointer items-center gap-2 border px-3 py-2 text-[12.5px] transition-colors"
- style={
- isActive
- ? {
- borderColor: "var(--cf-line-strong)",
- background: "var(--cf-ink)",
- color: "var(--cf-cream)",
- }
- : {
- borderColor: "var(--cf-line-strong)",
- color: "var(--cf-ink-soft)",
- }
- }
- >
-
- {f.title}
- {/* The dot is decorative; the state still needs a name. */}
- {f.isPublished ? "Published" : "Draft"}
-
- );
- })}
-
- )}
-
- );
-}
diff --git a/apps/web/components/analytics/DeviceBreakdown.tsx b/apps/web/components/analytics/DeviceBreakdown.tsx
deleted file mode 100644
index 02ee6ec..0000000
--- a/apps/web/components/analytics/DeviceBreakdown.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-"use client";
-
-import React from "react";
-import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
-
-interface DeviceData {
- name: string;
- value: number;
- color: string;
-}
-
-interface DeviceBreakdownProps {
- deviceData: DeviceData[];
-}
-
-export function DeviceBreakdown({ deviceData }: DeviceBreakdownProps) {
- const total = deviceData.reduce((sum, d) => sum + d.value, 0);
-
- return (
-
-
-
Devices
-
Device breakdown
-
From submissions
-
-
- {total === 0 ? (
-
- No device data recorded yet.
-
- ) : (
-
-
-
-
-
- {deviceData.map((entry, index) => (
- |
- ))}
-
-
-
-
- {total}
- total
-
-
-
-
- {deviceData.map((dev, idx) => {
- const pct = total > 0 ? ((dev.value / total) * 100).toFixed(0) : "0";
- return (
-
-
-
- {dev.name}
-
-
- {dev.value} ({pct}%)
-
-
- );
- })}
-
-
- )}
-
- );
-}
diff --git a/apps/web/components/analytics/EngagementStats.tsx b/apps/web/components/analytics/EngagementStats.tsx
deleted file mode 100644
index 9cef8de..0000000
--- a/apps/web/components/analytics/EngagementStats.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-"use client";
-
-import React from "react";
-import { Repeat, Timer, TrendingUp, Zap } from "lucide-react";
-
-import { SERIES } from "./palette";
-
-interface EngagementStatsProps {
- avgTimeSpentMs: number | null;
- medianResponseTime: number | null;
- returningRate: number;
- velocityFirst24h: number;
-}
-
-const formatDuration = (ms: number | null) => {
- if (ms == null || ms <= 0) return "—";
- const s = Math.round(ms / 1000);
- if (s < 60) return `${s}s`;
- const m = Math.floor(s / 60);
- const rem = s % 60;
- return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
-};
-
-const formatLag = (minutes: number | null) => {
- if (minutes == null) return "—";
- if (minutes < 60) return `${minutes}m`;
- const h = minutes / 60;
- if (h < 24) return `${h.toFixed(1)}h`;
- return `${(h / 24).toFixed(1)}d`;
-};
-
-export function EngagementStats({
- avgTimeSpentMs,
- medianResponseTime,
- returningRate,
- velocityFirst24h,
-}: EngagementStatsProps) {
- const stats = [
- {
- label: "Avg time to complete",
- value: formatDuration(avgTimeSpentMs),
- hint: "from open to submit",
- icon: Timer,
- colour: SERIES[0],
- },
- {
- label: "First 24h",
- value: velocityFirst24h.toLocaleString(),
- hint: "responses just after publish",
- icon: Zap,
- colour: SERIES[1],
- },
- {
- label: "Median lag",
- value: formatLag(medianResponseTime),
- hint: "publish to typical response",
- icon: TrendingUp,
- colour: SERIES[2],
- },
- {
- label: "Returning",
- value: `${returningRate}%`,
- hint: "repeat respondents (est.)",
- icon: Repeat,
- colour: SERIES[3],
- },
- ];
-
- return (
-
- {stats.map((s) => {
- const Icon = s.icon;
- return (
-
-
-
- {s.value}
-
-
- {s.hint}
-
-
- );
- })}
-
- );
-}
diff --git a/apps/web/components/analytics/FieldDropoff.tsx b/apps/web/components/analytics/FieldDropoff.tsx
deleted file mode 100644
index 7f3a686..0000000
--- a/apps/web/components/analytics/FieldDropoff.tsx
+++ /dev/null
@@ -1,364 +0,0 @@
-"use client";
-
-import React, { useMemo, useState } from "react";
-import { ChevronRight, Search, X } from "lucide-react";
-
-import { rateColor, SEMANTIC } from "./palette";
-
-interface FieldRate {
- fieldId: string;
- fieldLabel: string;
- rate: number;
-}
-
-interface FormField {
- id: string;
- label: string;
- type: string;
-}
-
-interface SubmissionValue {
- formFieldId: string;
- value: unknown;
-}
-
-interface Submission {
- id: string;
- values: SubmissionValue[];
- createdAt: string;
-}
-
-interface FieldDropoffProps {
- fieldCompletionRates: FieldRate[];
- fields: FormField[];
- submissions: Submission[];
-}
-
-const formatValue = (value: unknown): string => {
- if (value === null || value === undefined || value === "") return "";
- if (Array.isArray(value)) return value.map((v) => String(v)).join(", ");
- if (typeof value === "boolean") return value ? "Yes" : "No";
- if (typeof value === "object") return JSON.stringify(value);
- return String(value);
-};
-
-export function FieldDropoff({ fieldCompletionRates, fields, submissions }: FieldDropoffProps) {
- const [openFieldId, setOpenFieldId] = useState
(null);
-
- const answersByField = useMemo(() => {
- const map = new Map();
- for (const sub of submissions) {
- for (const v of sub.values ?? []) {
- const text = formatValue(v.value);
- if (!text) continue;
- const list = map.get(v.formFieldId) ?? [];
- list.push({ submissionId: sub.id, createdAt: sub.createdAt, text });
- map.set(v.formFieldId, list);
- }
- }
- return map;
- }, [submissions]);
-
- const rows = useMemo(() => {
- const byId = new Map(fieldCompletionRates.map((r) => [r.fieldId, r]));
- const ordered: (FieldRate & { type?: string })[] = [];
- for (const f of fields) {
- const r = byId.get(f.id);
- if (r) {
- ordered.push({ ...r, type: f.type });
- byId.delete(f.id);
- }
- }
- for (const leftover of byId.values()) ordered.push(leftover);
- return ordered;
- }, [fieldCompletionRates, fields]);
-
- const openField = rows.find((r) => r.fieldId === openFieldId) ?? null;
- const openAnswers = openFieldId ? (answersByField.get(openFieldId) ?? []) : [];
-
- if (rows.length === 0) {
- return (
-
-
No fields
-
- Add fields to this form to see per-question completion.
-
-
- );
- }
-
- return (
- <>
-
-
-
-
Drop-off by field
-
- Share of people who started the form and answered each field. Select one to read its
- answers.
-
-
-
{rows.length} fields
-
-
-
- {rows.map((row, i) => {
- const answered = answersByField.get(row.fieldId)?.length ?? 0;
- const colour = rateColor(row.rate);
- return (
-
- setOpenFieldId(row.fieldId)}
- className="group flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-(--cf-cream) sm:gap-4 sm:px-5"
- >
-
- {String(i + 1).padStart(2, "0")}
-
-
-
-
- {row.fieldLabel}
-
-
-
-
-
-
- {row.rate}%
-
-
-
-
-
- {answered} {answered === 1 ? "answer" : "answers"}
-
-
-
-
- );
- })}
-
-
-
- {openField && (
- setOpenFieldId(null)}
- />
- )}
- >
- );
-}
-
-/* ─── answers dialog ─────────────────────────────────────────────────── */
-
-function FieldAnswersDialog({
- label,
- rate,
- answers,
- onClose,
-}: {
- label: string;
- rate: number;
- answers: { submissionId: string; createdAt: string; text: string }[];
- onClose: () => void;
-}) {
- const [query, setQuery] = useState("");
-
- React.useEffect(() => {
- const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- document.addEventListener("keydown", onKey);
- return () => document.removeEventListener("keydown", onKey);
- }, [onClose]);
-
- const filtered = useMemo(() => {
- const q = query.trim().toLowerCase();
- if (!q) return answers;
- return answers.filter((a) => a.text.toLowerCase().includes(q));
- }, [answers, query]);
-
- const tally = useMemo(() => {
- const counts = new Map();
- for (const a of answers) counts.set(a.text, (counts.get(a.text) ?? 0) + 1);
- const list = [...counts.entries()].sort((a, b) => b[1] - a[1]);
- return list.length > 0 && list.length < answers.length ? list.slice(0, 6) : [];
- }, [answers]);
-
- return (
-
-
e.stopPropagation()}
- >
-
- {/* header */}
-
-
-
Field responses
-
- {label}
-
-
- {rate}% completion ·{" "}
- {answers.length} {" "}
- {answers.length === 1 ? "answer" : "answers"}
-
-
-
-
-
-
-
- {/* most common answers */}
- {tally.length > 0 && (
-
-
Most common
-
- {tally.map(([text, count]) => {
- const pct = answers.length > 0 ? (count / answers.length) * 100 : 0;
- return (
-
-
- {text}
-
-
-
-
-
- {count}
-
-
- );
- })}
-
-
- )}
-
- {/* search */}
-
-
-
-
- Search answers
-
- setQuery(e.target.value)}
- placeholder="Search answers..."
- className="cf-dark-input h-10 pr-3 pl-10 text-[13px]"
- />
-
-
-
- {/* answer list */}
-
- {filtered.length === 0 ? (
-
- {answers.length === 0
- ? "Nobody has answered this field yet."
- : "No answers match that search."}
-
- ) : (
-
- )}
-
-
-
-
- Showing {filtered.length} of {answers.length}
-
-
- Close
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/analytics/MetricsGrid.tsx b/apps/web/components/analytics/MetricsGrid.tsx
deleted file mode 100644
index 684caee..0000000
--- a/apps/web/components/analytics/MetricsGrid.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import React from "react";
-
-interface MetricsGridProps {
- totalResponses: number;
- avgPerDay: number;
-}
-
-export function MetricsGrid({ totalResponses, avgPerDay }: MetricsGridProps) {
- const stats = [
- {
- title: "Total responses",
- val: totalResponses.toLocaleString(),
- sub: `${avgPerDay.toFixed(1)} per day on average`,
- },
- {
- title: "Avg / day",
- val: avgPerDay.toFixed(1),
- sub: "Across the last 30 days",
- },
- ];
-
- return (
-
- {stats.map((stat) => (
-
-
- {stat.val}
-
-
- {stat.title}
-
-
- {stat.sub}
-
-
- ))}
-
- );
-}
diff --git a/apps/web/components/analytics/PeriodComparison.tsx b/apps/web/components/analytics/PeriodComparison.tsx
deleted file mode 100644
index 187bc1c..0000000
--- a/apps/web/components/analytics/PeriodComparison.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-"use client";
-
-import React from "react";
-
-import { SERIES } from "./palette";
-
-interface PeriodComparisonProps {
- trend30d: number;
- trend60d: number;
- trend90d: number;
-}
-
-export function PeriodComparison({ trend30d, trend60d, trend90d }: PeriodComparisonProps) {
- const buckets = [
- { label: "Last 30d", value: trend30d, colour: SERIES[0] },
- { label: "31–60d", value: Math.max(trend60d - trend30d, 0), colour: SERIES[1] },
- { label: "61–90d", value: Math.max(trend90d - trend60d, 0), colour: SERIES[3] },
- ];
-
- const max = Math.max(...buckets.map((b) => b.value), 1);
- const previous = buckets[1]!.value;
- const delta = previous > 0 ? ((trend30d - previous) / previous) * 100 : null;
-
- return (
-
-
-
-
Momentum
-
Last 90 days
-
- {delta !== null && (
-
= 0 ? SERIES[1] : SERIES[5] }}
- >
- {delta >= 0 ? "▲" : "▼"} {Math.abs(delta).toFixed(0)}% vs prior 30d
-
- )}
-
-
- {/* Column chart, drawn with divs — three bars do not justify pulling
- recharts into this panel. */}
-
- {buckets.map((b) => (
-
-
{b.value.toLocaleString()}
-
-
- {b.label}
-
-
- ))}
-
-
- );
-}
diff --git a/apps/web/components/analytics/QuestionDistribution.tsx b/apps/web/components/analytics/QuestionDistribution.tsx
deleted file mode 100644
index 1d68bbc..0000000
--- a/apps/web/components/analytics/QuestionDistribution.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import { ChevronDown, Star } from "lucide-react";
-
-import { SEMANTIC, seriesColor } from "./palette";
-
-interface OptionCount {
- value: string;
- count: number;
- percent: number;
-}
-interface ToggleCounts {
- yes: number;
- no: number;
-}
-interface RatingDistItem {
- rating: number;
- count: number;
- percent: number;
-}
-
-interface QuestionItem {
- fieldId: string;
- fieldLabel: string;
- fieldType: string;
- totalAnswered: number;
- optionCounts?: OptionCount[];
- averageRating?: number;
- ratingDistribution?: RatingDistItem[];
- toggleCounts?: ToggleCounts;
- textSamples?: string[];
-}
-
-interface QuestionDistributionProps {
- questionDistribution: QuestionItem[];
-}
-
-const CHOICE_TYPES = ["SELECT", "RADIO", "CHECKBOX"];
-const TEXT_TYPES = ["TEXT", "TEXTAREA", "EMAIL", "NUMBER", "PHONE", "URL", "DATE", "TIME"];
-
-function FieldTypePill({ type }: { type: string }) {
- return (
-
- {type.toLowerCase()}
-
- );
-}
-
-/** Choice bar. Each option gets its own hue so the split is readable at a glance. */
-function ChoiceBar({ option, index }: { option: OptionCount; index: number }) {
- const colour = seriesColor(index);
- return (
-
-
- {option.value}
-
- {option.count} ({option.percent}%)
-
-
-
-
- );
-}
-
-function RatingStars({ avg }: { avg: number }) {
- const full = Math.floor(avg);
- const frac = avg - full;
- return (
-
-
- {Array.from({ length: 5 }).map((_, i) => {
- const filled = i < full ? 1 : i === full && frac > 0 ? frac : 0;
- return (
-
- );
- })}
-
-
{avg.toFixed(1)}
-
avg
-
- );
-}
-
-function RatingDistBar({ dist }: { dist: RatingDistItem[] }) {
- return (
-
- {dist.map((d) => (
-
-
- {d.rating}
-
-
-
-
- {d.count}
-
-
- ))}
-
- );
-}
-
-function ToggleBar({ counts }: { counts: ToggleCounts }) {
- const total = counts.yes + counts.no;
- const yesPct = total > 0 ? Math.round((counts.yes / total) * 100) : 0;
- const noPct = 100 - yesPct;
- return (
-
-
-
- {[
- { label: "Yes", n: counts.yes, pct: yesPct, colour: SEMANTIC.good },
- { label: "No", n: counts.no, pct: noPct, colour: SEMANTIC.bad },
- ].map((r) => (
-
-
- {r.label}
-
- {r.n} ({r.pct}%)
-
-
- ))}
-
-
- );
-}
-
-function TextSamples({ samples, totalAnswered }: { samples: string[]; totalAnswered: number }) {
- const shown = samples.slice(0, 3);
- const remaining = totalAnswered - shown.length;
- return (
-
- {shown.map((s, i) => (
-
- {s.length > 80 ? s.slice(0, 80) + "…" : s}
-
- ))}
- {remaining > 0 && (
-
- +{remaining} more
-
- )}
-
- );
-}
-
-function collapsedSummary(q: QuestionItem): string | null {
- if (q.totalAnswered === 0) return "No answers yet";
- if (CHOICE_TYPES.includes(q.fieldType) && q.optionCounts?.length) {
- const top = q.optionCounts[0]!;
- return `Top: ${top.value} (${top.percent}%)`;
- }
- if (q.fieldType === "RATING" && q.averageRating !== undefined) {
- return `Average ${q.averageRating.toFixed(1)} of 5`;
- }
- if (q.fieldType === "TOGGLE" && q.toggleCounts) {
- const total = q.toggleCounts.yes + q.toggleCounts.no;
- const pct = total > 0 ? Math.round((q.toggleCounts.yes / total) * 100) : 0;
- return `${pct}% answered yes`;
- }
- if (TEXT_TYPES.includes(q.fieldType)) return "Free text answers";
- return null;
-}
-
-function QuestionBody({ q }: { q: QuestionItem }) {
- const isChoice = CHOICE_TYPES.includes(q.fieldType);
- const isText = TEXT_TYPES.includes(q.fieldType);
- const isRating = q.fieldType === "RATING";
- const isToggle = q.fieldType === "TOGGLE";
-
- if (q.totalAnswered === 0) {
- return (
-
- 0 responses for this field
-
- );
- }
-
- return (
- <>
- {isChoice && q.optionCounts && (
-
- {q.optionCounts.map((opt, i) => (
-
- ))}
-
- )}
-
- {isRating && (
-
- {q.averageRating !== undefined && }
- {q.ratingDistribution && }
-
- )}
-
- {isToggle && q.toggleCounts && }
-
- {isText && (
-
-
Recent answers
- {q.textSamples && q.textSamples.length > 0 ? (
-
- ) : (
-
- {q.totalAnswered} answer{q.totalAnswered !== 1 ? "s" : ""} recorded
-
- )}
-
- )}
-
- {!isChoice && !isRating && !isToggle && !isText && (
-
- {q.totalAnswered} answer{q.totalAnswered !== 1 ? "s" : ""} recorded
-
- )}
- >
- );
-}
-
-export function QuestionDistribution({ questionDistribution }: QuestionDistributionProps) {
- const [openId, setOpenId] = useState(null);
-
- return (
-
-
-
-
Questions
-
Question breakdown
-
- Select a question to see how it was answered.
-
-
-
{questionDistribution.length} fields
-
-
- {questionDistribution.length === 0 ? (
-
- No fields found for this form.
-
- ) : (
-
- {questionDistribution.map((q, i) => {
- const isOpen = openId === q.fieldId;
- const summary = collapsedSummary(q);
- return (
-
- setOpenId(isOpen ? null : q.fieldId)}
- className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-(--cf-cream) sm:px-5"
- >
-
- {String(i + 1).padStart(2, "0")}
-
-
-
- {q.fieldLabel}
-
-
-
- {q.totalAnswered} response{q.totalAnswered !== 1 ? "s" : ""}
-
- {summary && (
- <>
-
- ·
-
-
- {summary}
-
- >
- )}
-
-
-
-
-
-
- {isOpen && (
-
-
-
- )}
-
- );
- })}
-
- )}
-
- );
-}
diff --git a/apps/web/components/analytics/ResponseTimeline.tsx b/apps/web/components/analytics/ResponseTimeline.tsx
deleted file mode 100644
index e9bd8ba..0000000
--- a/apps/web/components/analytics/ResponseTimeline.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-"use client";
-
-import React from "react";
-import {
- Area,
- AreaChart,
- ResponsiveContainer,
- Tooltip as ChartTooltip,
- XAxis,
- YAxis,
-} from "recharts";
-
-import { CHROME, SEMANTIC } from "./palette";
-
-interface TrendPoint {
- date: string;
- count: number;
-}
-
-interface ResponseTimelineProps {
- totalResponses: number;
- trends: TrendPoint[];
-}
-
-export function ResponseTimeline({ totalResponses, trends }: ResponseTimelineProps) {
- const chartData = trends.map((t) => ({ name: t.date, Responses: t.count }));
-
- return (
-
-
-
-
Timeline
-
Response timeline
-
Last 30 days
-
-
-
- Responses
-
-
-
- {totalResponses === 0 ? (
-
- No responses recorded.
-
- ) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
-}
diff --git a/apps/web/components/analytics/StatsRow.tsx b/apps/web/components/analytics/StatsRow.tsx
deleted file mode 100644
index 010a617..0000000
--- a/apps/web/components/analytics/StatsRow.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import React from "react";
-import { BarChart2, Calendar, RefreshCw, Zap } from "lucide-react";
-
-interface StatsRowProps {
- peakDay: string | null;
- avgPerWeek: number;
- velocityFirst24h?: number;
- returningRate?: number;
-}
-
-interface ChipDef {
- label: string;
- value: string;
- icon: React.ElementType;
-}
-
-export function StatsRow({ peakDay, avgPerWeek, velocityFirst24h, returningRate }: StatsRowProps) {
- const chips: ChipDef[] = [
- { label: "Peak day", value: peakDay ?? "—", icon: Calendar },
- { label: "Avg / week", value: avgPerWeek.toFixed(1), icon: BarChart2 },
- ...(velocityFirst24h !== undefined
- ? [
- {
- label: "Launch velocity",
- value: velocityFirst24h.toLocaleString(),
- icon: Zap,
- },
- ]
- : []),
- ...(returningRate !== undefined
- ? [
- {
- label: "Returning",
- value: returningRate > 0 ? returningRate.toFixed(1) + "%" : "—",
- icon: RefreshCw,
- },
- ]
- : []),
- ];
-
- return (
-
-
- {chips.map((chip, idx) => {
- const Icon = chip.icon;
- return (
-
-
- {chip.label}
-
- {chip.value}
-
-
- );
- })}
-
-
- );
-}
diff --git a/apps/web/components/analytics/SubmissionDetailModal.tsx b/apps/web/components/analytics/SubmissionDetailModal.tsx
deleted file mode 100644
index 0add771..0000000
--- a/apps/web/components/analytics/SubmissionDetailModal.tsx
+++ /dev/null
@@ -1,174 +0,0 @@
-"use client";
-
-import React from "react";
-import { Download, X } from "lucide-react";
-
-import { ModalOverlay } from "~/components/ui/ModalOverlay";
-import { downloadUrlFor, formatBytes } from "~/lib/upload";
-
-interface SubmissionValue {
- formFieldId: string;
- value: any;
-}
-
-interface Submission {
- id: string;
- formId: string;
- values: SubmissionValue[];
- createdAt: string;
-}
-
-interface FormField {
- id: string;
- label: string;
- type: string;
-}
-
-interface SubmissionDetailModalProps {
- submission: Submission;
- form: { fields: FormField[] };
- getRespondentDetails: (sub: Submission) => { name: string; email: string };
- onClose: () => void;
-}
-
-export function SubmissionDetailModal({
- submission,
- form,
- getRespondentDetails,
- onClose,
-}: SubmissionDetailModalProps) {
- const respondent = getRespondentDetails(submission);
-
- return (
-
-
-
-
-
-
-
-
Submission
-
- {respondent.name}
-
-
- {new Date(submission.createdAt).toLocaleString()}
-
-
-
-
- {form.fields.map((field) => {
- const answer = submission.values.find((v) => v.formFieldId === field.id);
- const hasValue =
- answer?.value !== undefined && answer?.value !== null && answer?.value !== "";
-
- if (field.type === "FILE_UPLOAD" && hasValue) {
- const filesRaw = Array.isArray(answer?.value) ? answer.value : [answer?.value];
- const files = filesRaw.filter(Boolean) as Array<{
- uploadId?: string;
- name?: string;
- url?: string | null;
- status?: string;
- sizeBytes?: number;
- }>;
-
- return (
-
-
{field.label}
-
- {files.length === 0 ? (
-
No answer provided
- ) : (
- files.map((file, i) => (
-
- {file.url ? (
- <>
-
- {file.name || "Attachment"}
-
-
-
-
-
- >
- ) : (
-
- {file.name || "Attachment"}
-
- )}
-
- {typeof file.sizeBytes === "number" && (
-
- {formatBytes(file.sizeBytes)}
-
- )}
-
- {!file.url && (
-
- {file.status === "failed" ? "failed" : "still processing"}
-
- )}
-
- ))
- )}
-
-
- );
- }
-
- let displayVal = "No answer provided";
- if (hasValue) {
- if (Array.isArray(answer.value)) {
- displayVal = answer.value.join(", ");
- } else if (typeof answer.value === "boolean") {
- displayVal = answer.value ? "Yes" : "No";
- } else {
- displayVal = String(answer.value);
- }
- }
-
- return (
-
-
{field.label}
-
- {displayVal}
-
-
- );
- })}
-
-
-
-
- Close
-
-
-
-
- );
-}
diff --git a/apps/web/components/analytics/SubmissionsTable.tsx b/apps/web/components/analytics/SubmissionsTable.tsx
deleted file mode 100644
index f2f70f9..0000000
--- a/apps/web/components/analytics/SubmissionsTable.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-"use client";
-
-import React, { useMemo, useRef } from "react";
-import dynamic from "next/dynamic";
-import { Eye, Search } from "lucide-react";
-import { useVirtualizer } from "@tanstack/react-virtual";
-
-interface SubmissionValue {
- formFieldId: string;
- value: any;
-}
-
-interface Submission {
- id: string;
- formId: string;
- values: SubmissionValue[];
- createdAt: string;
-}
-
-interface FormField {
- id: string;
- label: string;
- type: string;
-}
-
-interface SubmissionsTableProps {
- filteredSubmissions: Submission[];
- searchQuery: string;
- setSearchQuery: (q: string) => void;
- getRespondentDetails: (sub: Submission) => { name: string; email: string };
- setViewingSubmission: (sub: Submission | null) => void;
- viewingSubmission: Submission | null;
- form: { fields: FormField[] } | null | undefined;
- hasNextPage?: boolean;
- isFetchingNextPage?: boolean;
- fetchNextPage?: () => void;
-}
-
-const SubmissionDetailModal = dynamic(
- () => import("./SubmissionDetailModal").then((m) => m.SubmissionDetailModal),
- { ssr: false },
-);
-
-const ROW_HEIGHT = 64;
-const GRID_COLS =
- "grid grid-cols-[minmax(0,1fr)_110px_160px_56px] sm:grid-cols-[minmax(0,1fr)_120px_180px_56px] gap-3 items-center";
-
-export function SubmissionsTable({
- filteredSubmissions,
- searchQuery,
- setSearchQuery,
- getRespondentDetails,
- setViewingSubmission,
- viewingSubmission,
- form,
- hasNextPage,
- isFetchingNextPage,
- fetchNextPage,
-}: SubmissionsTableProps) {
- const scrollRef = useRef(null);
-
- const rowVirtualizer = useVirtualizer({
- count: filteredSubmissions.length,
- getScrollElement: () => scrollRef.current,
- estimateSize: () => ROW_HEIGHT,
- overscan: 6,
- });
-
- const virtualItems = rowVirtualizer.getVirtualItems();
- const totalSize = rowVirtualizer.getTotalSize();
-
- React.useEffect(() => {
- const last = virtualItems[virtualItems.length - 1];
- if (!last) return;
- if (hasNextPage && !isFetchingNextPage && last.index >= filteredSubmissions.length - 3) {
- fetchNextPage?.();
- }
- }, [virtualItems, hasNextPage, isFetchingNextPage, filteredSubmissions.length, fetchNextPage]);
-
- const scrollHeight = useMemo(() => {
- if (filteredSubmissions.length === 0) return 0;
- return Math.min(filteredSubmissions.length * ROW_HEIGHT, 640);
- }, [filteredSubmissions.length]);
-
- return (
- <>
-
-
-
-
Latest
-
Responses
- {filteredSubmissions.length > 0 && (
-
- {filteredSubmissions.length} {filteredSubmissions.length === 1 ? "row" : "rows"} ·
- virtualised
- {hasNextPage ? " · more available" : ""}
-
- )}
-
-
- setSearchQuery(e.target.value)}
- className="w-full bg-(--cf-cream) rounded-md ring-1 ring-(--cf-line) focus:ring-2 focus:ring-(--cf-orange) focus:outline-none pl-9 pr-3 h-9.5 text-[13px] text-(--cf-ink) placeholder:text-(--cf-ink-soft)/55 transition-shadow"
- />
-
-
-
-
-
- {filteredSubmissions.length === 0 ? (
-
- No submissions found.
-
- ) : (
-
-
-
-
- Respondent
-
-
- Status
-
-
- Date
-
-
- Action
-
-
-
-
- {virtualItems.map((vi) => {
- const sub = filteredSubmissions[vi.index]!;
- const details = getRespondentDetails(sub);
- const initials = details.name.substring(0, 2).toUpperCase();
-
- return (
-
- {/* Respondent */}
-
-
- {initials || "?"}
-
-
-
- {details.name}
-
-
- {details.email}
-
-
-
-
- {/* Status */}
-
-
- Completed
-
-
- {/* Date */}
-
- {new Date(sub.createdAt).toLocaleString()}
-
-
- {/* Action */}
-
- setViewingSubmission(sub)}
- className="p-2 rounded-md ring-1 ring-(--cf-line-strong) hover:bg-(--cf-cream) text-(--cf-ink) transition-colors cursor-pointer"
- title="View details"
- aria-label="View submission details"
- >
-
-
-
-
- );
- })}
-
-
-
- {(hasNextPage || isFetchingNextPage) && (
-
- {isFetchingNextPage ? (
-
-
- Loading older submissions...
-
- ) : (
- fetchNextPage?.()}
- className="text-[12px] font-medium text-(--cf-ink) hover:text-(--cf-orange) ring-1 ring-(--cf-line-strong) hover:ring-(--cf-orange) bg-(--cf-cream) hover:bg-(--cf-cream-2) px-4 h-8 rounded-full transition-colors cursor-pointer"
- >
- Load older submissions
-
- )}
-
- )}
-
-
- )}
-
-
-
- {viewingSubmission && form && (
- setViewingSubmission(null)}
- />
- )}
- >
- );
-}
diff --git a/apps/web/components/analytics/TrafficSources.tsx b/apps/web/components/analytics/TrafficSources.tsx
deleted file mode 100644
index 9858697..0000000
--- a/apps/web/components/analytics/TrafficSources.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import React from "react";
-import { Globe } from "lucide-react";
-
-import { seriesColor } from "./palette";
-
-interface TrafficSourcesProps {
- topReferrers: Array<{ referrer: string; count: number }>;
-}
-
-export function TrafficSources({ topReferrers }: TrafficSourcesProps) {
- const max = Math.max(...topReferrers.map((r) => r.count), 1);
- const total = topReferrers.reduce((sum, r) => sum + r.count, 0);
-
- return (
-
-
-
-
-
Where responses came from
-
- {total > 0 && (
-
-
- {total.toLocaleString()}
-
-
attributed
-
- )}
-
-
- {topReferrers.length === 0 ? (
-
- No referrer data yet. Attribution is recorded when someone opens the form from a link.
-
- ) : (
-
- {topReferrers.map((r, i) => {
- const barPct = (r.count / max) * 100;
- const sharePct = total > 0 ? (r.count / total) * 100 : 0;
- return (
-
-
- {String(i + 1).padStart(2, "0")}
-
-
-
-
-
- {r.referrer}
-
-
- {r.count.toLocaleString()}
-
- {sharePct.toFixed(0)}%
-
-
-
-
-
-
- );
- })}
-
- )}
-
- );
-}
diff --git a/apps/web/components/analytics/palette.ts b/apps/web/components/analytics/palette.ts
deleted file mode 100644
index 331aeb5..0000000
--- a/apps/web/components/analytics/palette.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-export const SERIES = [
- "#2d5cf6", // accent blue — first series, matches the primary action
- "#3aa793", // teal
- "#e3b23c", // yellow
- "#6c5ce7", // purple
- "#e0834a", // orange
- "#dd6459", // red
- "#a79ae4", // lavender
-] as const;
-
-export const seriesColor = (i: number) => SERIES[i % SERIES.length] as string;
-
-export const SEMANTIC = {
- good: "#3aa793",
- warn: "#e3b23c",
- bad: "#dd6459",
- accent: "#2d5cf6",
-} as const;
-
-export const rateColor = (rate: number) => {
- if (rate >= 75) return SEMANTIC.good;
- if (rate >= 40) return SEMANTIC.warn;
- return SEMANTIC.bad;
-};
-
-export const CHROME = {
- axis: "#5b6070",
- grid: "rgba(26,29,41,0.10)",
- ink: "#1a1d29",
- surface: "#e8e8e8",
-} as const;
-
-export const DEVICE_COLORS = {
- Desktop: "#2d5cf6",
- Mobile: "#3aa793",
- Tablet: "#e3b23c",
-} as const;
diff --git a/apps/web/components/builder/BuilderHeader.tsx b/apps/web/components/builder/BuilderHeader.tsx
index 6e4997a..192befb 100644
--- a/apps/web/components/builder/BuilderHeader.tsx
+++ b/apps/web/components/builder/BuilderHeader.tsx
@@ -21,16 +21,17 @@ export type BuilderView = "canvas" | "outline";
interface BuilderHeaderProps {
form:
- | {
- title: string;
- description?: string | null;
- isPublished: boolean;
- ownerEmail?: string | null;
- role?: "owner" | "editor" | "viewer";
- permissions?: any;
- }
- | null
- | undefined;
+ | {
+ title: string;
+ description?: string | null;
+ isPublished: boolean;
+ ownerEmail?: string | null;
+ role?: "owner" | "editor" | "viewer";
+ permissions?: any;
+ submissionsCount?: number | null;
+ }
+ | null
+ | undefined;
formId: string;
isDirty: boolean;
isSaving: boolean;
@@ -70,7 +71,6 @@ export function BuilderHeader({
onViewChange,
}: BuilderHeaderProps) {
const isPublished = form?.isPublished ?? false;
- const isOwner = form?.role === "owner";
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef(null);
const canDelete = form?.permissions?.settings?.canDelete ?? form?.role === "owner";
@@ -126,16 +126,14 @@ export function BuilderHeader({
{/* status pill — always visible */}
{isPublished ? "Live" : "Draft"}
@@ -150,58 +148,56 @@ export function BuilderHeader({
{/* right: actions */}
-
- {(
- [
- { id: "canvas", label: "Canvas", Icon: LayoutGrid },
- { id: "outline", label: "Outline", Icon: ListOrdered },
- ] as const
- ).map(({ id, label, Icon }) => {
- const active = view === id;
- return (
- onViewChange(id)}
- aria-pressed={active}
- title={id === "canvas" ? "Canvas builder" : "Outline builder"}
- className={`inline-flex h-7.5 cursor-pointer items-center gap-1.5 px-2.5 font-mono text-[10px] tracking-wider uppercase transition-colors ${
- active
- ? "bg-(--cf-ink) text-(--cf-cream)"
- : "text-(--cf-ink-soft) hover:text-(--cf-ink)"
- }`}
- >
-
- {label}
-
- );
- })}
-
+
+ {(
+ [
+ { id: "canvas", label: "Canvas", Icon: LayoutGrid },
+ { id: "outline", label: "Outline", Icon: ListOrdered },
+ ] as const
+ ).map(({ id, label, Icon }) => {
+ const active = view === id;
+ return (
+ onViewChange(id)}
+ aria-pressed={active}
+ title={id === "canvas" ? "Canvas builder" : "Outline builder"}
+ className={`inline-flex h-7.5 cursor-pointer items-center gap-1.5 px-2.5 font-mono text-[10px] tracking-wider uppercase transition-colors ${active
+ ? "bg-(--cf-ink) text-(--cf-cream)"
+ : "text-(--cf-ink-soft) hover:text-(--cf-ink)"
+ }`}
+ >
+
+ {label}
+
+ );
+ })}
+
-
+
- {/* Save — always visible, icon-only on phone */}
-
- {justSaved && !isSaving ? : }
-
- {isSaving ? "Saving..." : justSaved ? "Saved" : "Save"}
-
-
+ {/* Save — always visible, icon-only on phone */}
+
+ {justSaved && !isSaving ? : }
+
+ {isSaving ? "Saving..." : justSaved ? "Saved" : "Save"}
+
+
Share
- {isOwner && (
-
-
- Settings
-
- )}
+
+
+ Settings
+
{canDelete && (
<>
@@ -286,18 +280,6 @@ export function BuilderHeader({
Share form
- {isOwner && (
-
{
- onSettings();
- setMenuOpen(false);
- }}
- className="cf-menu-item flex w-full items-center gap-2.5 py-2! text-[13px]"
- >
-
- Settings
-
- )}
{canDelete && (
<>
@@ -317,7 +299,7 @@ export function BuilderHeader({
)}
- {/* Publish — always visible, always primary */}
+ {/* Publish — only on questions tab */}
{
if (isDirty) await handleSave();
diff --git a/apps/web/components/builder/ResponsesView.tsx b/apps/web/components/builder/ResponsesView.tsx
new file mode 100644
index 0000000..f38fed7
--- /dev/null
+++ b/apps/web/components/builder/ResponsesView.tsx
@@ -0,0 +1,724 @@
+"use client";
+
+import React, { useMemo, useState } from "react";
+import Link from "next/link";
+import {
+ Download,
+ Share2,
+ Edit3,
+ FileText,
+ Eye,
+ X,
+ Clock,
+ Inbox,
+ Mail,
+ ArrowLeft,
+ ChevronLeft,
+ ChevronRight,
+} from "lucide-react";
+import {
+ Bar,
+ BarChart,
+ Cell,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+ Pie,
+ PieChart,
+} from "recharts";
+import { useGetSubmissions } from "~/hooks/api/analytics";
+import { getFieldOptionsArray } from "~/components/builder/FormFieldNode";
+import { apiOrigin, downloadUrlFor } from "~/lib/upload";
+
+// Helper to resolve absolute API URL if path is relative
+const resolveFileUrl = (urlVal: string | null | undefined): string => {
+ if (!urlVal) return "";
+ if (urlVal.startsWith("http://") || urlVal.startsWith("https://")) {
+ return urlVal;
+ }
+ const origin = apiOrigin();
+ const cleanPath = urlVal.startsWith("/") ? urlVal : `/${urlVal}`;
+ return `${origin}${cleanPath}`;
+};
+
+const FileUploadCell = ({ val }: { val: any }) => {
+ // Normalize value to array of files (filtering empty values)
+ const items = Array.isArray(val) ? val : [val].filter(Boolean);
+
+ if (items.length === 0) {
+ return No file uploaded ;
+ }
+
+ return (
+
+ {items.map((item, idx) => {
+ const url = typeof item === "string" ? item : item?.url;
+ const name = typeof item === "string" ? item.split("/").pop() : item?.originalName || item?.name || "Uploaded File";
+ const fileUrl = resolveFileUrl(url);
+ const dlUrl = downloadUrlFor(fileUrl, name);
+
+ if (!fileUrl) {
+ return (
+
+ No link available
+
+ );
+ }
+
+ return (
+
+
+
+ {name}
+
+ |
+
+
+ Download
+
+
+ );
+ })}
+
+ );
+};
+
+// Standard CanvasFlow retro-modern theme colors
+const CHART_COLORS = [
+ "#d95d39", // coral/orange
+ "#d4a359", // amber/gold
+ "#2a9d8f", // teal/emerald
+ "#5c54ed", // indigo
+ "#0ea5e9", // sky
+ "#ec4899", // pink
+];
+
+interface ResponsesViewProps {
+ formId: string;
+ fields: any[];
+ segments?: any[];
+ submissionsCount?: number;
+ formTitle?: string;
+ onNavigateTab?: (tab: "questions" | "responses") => void;
+ onShare?: () => void;
+}
+
+export function ResponsesView({
+ formId,
+ fields,
+ formTitle = "Onboarding Survey",
+ onNavigateTab,
+ onShare,
+}: ResponsesViewProps) {
+ const { submissions, isLoading } = useGetSubmissions(formId);
+ const [subTab, setSubTab] = useState<"summary" | "question" | "responses">("summary");
+
+ // Selected question for the Question sub-tab
+ const [selectedFieldId, setSelectedFieldId] = useState("");
+ const [viewingSub, setViewingSub] = useState(null);
+
+ // Set default selected field once fields load
+ React.useEffect(() => {
+ if (fields.length > 0 && !selectedFieldId) {
+ setSelectedFieldId(fields[0].id);
+ }
+ }, [fields, selectedFieldId]);
+
+ // Export CSV
+ const handleExportCsv = () => {
+ if (submissions.length === 0) return;
+ const headers = ["Submission ID", "Submitted At", "Respondent Email", "Device Type"];
+ fields.forEach((f) => headers.push(f.label || "Untitled Question"));
+
+ const rows = submissions.map((sub) => {
+ const metadata = [
+ sub.id,
+ new Date(sub.createdAt).toLocaleString(),
+ sub.respondentEmail || "Anonymous",
+ sub.deviceType || "Unknown",
+ ];
+ const fieldValues = fields.map((f) => {
+ const valObj = sub.values.find((v: any) => v.formFieldId === f.id);
+ const val = valObj ? valObj.value : "";
+ if (Array.isArray(val)) return `"${val.join(", ")}"`;
+ if (typeof val === "object" && val !== null) return `"${JSON.stringify(val)}"`;
+ return `"${String(val ?? "").replace(/"/g, '""')}"`;
+ });
+ return [...metadata, ...fieldValues];
+ });
+
+ const csvContent = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
+ const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.setAttribute("href", url);
+ link.setAttribute("download", `responses_${formId}.csv`);
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ // General summary metrics
+ const summaryMetrics = useMemo(() => {
+ const totalCount = submissions.length;
+ const deviceCounts = { desktop: 0, mobile: 0, tablet: 0 };
+ let totalTime = 0;
+ let timeCount = 0;
+
+ submissions.forEach((sub) => {
+ const dev = (sub.deviceType || "desktop").toLowerCase();
+ if (dev.includes("mobile")) deviceCounts.mobile++;
+ else if (dev.includes("tablet")) deviceCounts.tablet++;
+ else deviceCounts.desktop++;
+
+ if (sub.timeSpentMs && sub.timeSpentMs > 0) {
+ totalTime += sub.timeSpentMs;
+ timeCount++;
+ }
+ });
+
+ const avgTimeMs = timeCount > 0 ? totalTime / timeCount : 0;
+ const totalSecs = Math.round(avgTimeMs / 1000);
+ const mins = Math.floor(totalSecs / 60);
+ const secs = totalSecs % 60;
+ const formattedAvgTime = totalSecs > 0 ? `${mins}:${secs < 10 ? "0" : ""}${secs}` : "—";
+
+ return {
+ total: totalCount,
+ completionRate: totalCount > 0 ? "100%" : "0%",
+ avgTime: formattedAvgTime,
+ };
+ }, [submissions]);
+
+ // Per-question summaries
+ const questionSummaries = useMemo(() => {
+ return fields.map((field) => {
+ const answers: any[] = [];
+ submissions.forEach((sub) => {
+ const entry = sub.values.find((v: any) => v.formFieldId === field.id);
+ if (entry && entry.value !== undefined && entry.value !== null && entry.value !== "") {
+ answers.push({
+ value: entry.value,
+ respondent: sub.respondentEmail || "Anonymous",
+ submittedAt: sub.createdAt,
+ });
+ }
+ });
+
+ let chartData: any[] = [];
+ const isChoice = ["RADIO", "SELECT", "CHECKBOX", "TOGGLE", "RATING"].includes(field.type);
+
+ if (isChoice) {
+ const counts: Record = {};
+ if (field.type === "CHECKBOX") {
+ answers.forEach((ans) => {
+ const arr = Array.isArray(ans.value) ? ans.value : [ans.value];
+ arr.forEach((val: any) => {
+ const label = String(val);
+ counts[label] = (counts[label] || 0) + 1;
+ });
+ });
+ } else if (field.type === "TOGGLE") {
+ answers.forEach((ans) => {
+ const label = ans.value === true || String(ans.value).toLowerCase() === "true" ? "Yes" : "No";
+ counts[label] = (counts[label] || 0) + 1;
+ });
+ } else if (field.type === "RATING") {
+ [1, 2, 3, 4, 5].forEach((num) => { counts[String(num)] = 0; });
+ answers.forEach((ans) => {
+ const label = String(ans.value);
+ if (counts[label] !== undefined) counts[label]++;
+ });
+ } else {
+ answers.forEach((ans) => {
+ const label = String(ans.value);
+ counts[label] = (counts[label] || 0) + 1;
+ });
+ }
+
+ const options = getFieldOptionsArray(field);
+ if (field.type === "RATING") {
+ chartData = [1, 2, 3, 4, 5].map((num) => ({
+ name: `${num} ★`,
+ count: counts[String(num)] || 0,
+ }));
+ } else if (field.type === "TOGGLE") {
+ chartData = [
+ { name: "Yes", count: counts["Yes"] || 0 },
+ { name: "No", count: counts["No"] || 0 },
+ ];
+ } else {
+ chartData = options.map((opt) => ({
+ name: String(opt),
+ count: counts[String(opt)] || 0,
+ }));
+ }
+ }
+
+ let ratingAvg = 0;
+ if (field.type === "RATING" && answers.length > 0) {
+ const sum = answers.reduce((acc, ans) => acc + Number(ans.value || 0), 0);
+ ratingAvg = sum / answers.length;
+ }
+
+ return { field, answers, chartData, isChoice, ratingAvg };
+ });
+ }, [fields, submissions]);
+
+ // Selected question summary for the Question sub-tab
+ const selectedQuestionSummary = useMemo(() => {
+ return questionSummaries.find((qs) => qs.field.id === selectedFieldId);
+ }, [questionSummaries, selectedFieldId]);
+
+ if (isLoading) {
+ return (
+
+
+
Loading responses...
+
+ );
+ }
+
+ return (
+
+ {/* ── Top App Bar matching Mockup Header ── */}
+
+
+
+
+
+
+ CanvasFlow
+ ·
+ {formTitle}
+
+
+
+ onNavigateTab?.("questions")}
+ className="cf-btn-outline h-8 px-3 text-[12px] font-medium inline-flex items-center gap-1.5"
+ >
+
+ Edit
+
+
+
+ Share
+
+
+
+
+
+ {/* ── Main Title Area ── */}
+
+
+ {submissions.length} {submissions.length === 1 ? "Response" : "Responses"}
+ .
+
+
+
+ {/* ── Google Forms Sub-navigation Tabs ── */}
+
+
+ setSubTab("summary")}
+ className={`pb-2.5 font-medium transition-colors border-b-2 cursor-pointer ${
+ subTab === "summary"
+ ? "border-(--cf-ink) text-(--cf-ink) font-semibold"
+ : "border-transparent text-(--cf-ink-soft) hover:text-(--cf-ink)"
+ }`}
+ >
+ Summary
+
+ setSubTab("question")}
+ className={`pb-2.5 font-medium transition-colors border-b-2 cursor-pointer ${
+ subTab === "question"
+ ? "border-(--cf-ink) text-(--cf-ink) font-semibold"
+ : "border-transparent text-(--cf-ink-soft) hover:text-(--cf-ink)"
+ }`}
+ >
+ Question
+
+ setSubTab("responses")}
+ className={`pb-2.5 font-medium transition-colors border-b-2 cursor-pointer ${
+ subTab === "responses"
+ ? "border-(--cf-ink) text-(--cf-ink) font-semibold"
+ : "border-transparent text-(--cf-ink-soft) hover:text-(--cf-ink)"
+ }`}
+ >
+ Individual
+
+
+
+
+
+ CSV Export
+
+
+
+ {/* ── 1. SUMMARY VIEW ── */}
+ {subTab === "summary" && (
+
+ {/* Top 3 Metric Cards */}
+
+
+
+ {submissions.length}
+
+
Total responses
+
+
+
+ {summaryMetrics.completionRate}
+
+
Completion rate
+
+
+
+ {summaryMetrics.avgTime}
+
+
Avg. time
+
+
+
+ {/* Questions breakdown */}
+ {submissions.length === 0 ? (
+
+
+
No submissions yet
+
Waiting for responses
+
+ ) : (
+
+ {questionSummaries.map(({ field, answers, chartData, isChoice, ratingAvg }) => (
+
+
+
+
+ {field.label || "Untitled Question"}
+
+
+ {answers.length} {answers.length === 1 ? "response" : "responses"}
+
+
+
+ {field.type}
+
+
+
+ {answers.length === 0 ? (
+
No answers submitted yet for this field.
+ ) : isChoice ? (
+
+ {field.type === "RATING" && (
+
+ Average Rating:
+
+ {ratingAvg.toFixed(2)} / 5.00
+
+
+ )}
+
+ {["RADIO", "SELECT", "TOGGLE"].includes(field.type) ? (
+
+
+
+
+
+ {chartData.map((_, index) => (
+ |
+ ))}
+
+ `${val} response(s)`} />
+
+
+
+
+ {chartData.map((d, index) => {
+ const pct = answers.length > 0 ? ((d.count / answers.length) * 100).toFixed(1) : 0;
+ return (
+
+
+
+ {d.name}
+
+ {d.count} ({pct}%)
+
+ );
+ })}
+
+
+ ) : (
+
+
+
+
+
+ `${val} response(s)`} />
+
+ {chartData.map((_, index) => (
+ |
+ ))}
+
+
+
+
+ )}
+
+ ) : (
+
+ {answers.map((ans, aIdx) => (
+
+
+ {field.type === "FILE_UPLOAD" ? (
+
+ ) : (
+ String(ans.value)
+ )}
+
+
+ {ans.respondent}
+ {new Date(ans.submittedAt).toLocaleDateString()}
+
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* ── 2. QUESTION VIEW ── */}
+ {subTab === "question" && (
+
+ {fields.length === 0 ? (
+
No fields in this form.
+ ) : (
+ <>
+ {/* Selector */}
+
+
+ setSelectedFieldId(e.target.value)}
+ className="w-full bg-white border border-(--cf-line-strong) px-3 py-1.5 text-[13px] font-medium text-(--cf-ink) focus:outline-none focus:border-(--cf-orange)"
+ >
+ {fields.map((f, idx) => (
+
+ {idx + 1}. {f.label || "Untitled Question"}
+
+ ))}
+
+
+
+ {
+ const idx = fields.findIndex((f) => f.id === selectedFieldId);
+ if (idx > 0) setSelectedFieldId(fields[idx - 1].id);
+ }}
+ disabled={fields.findIndex((f) => f.id === selectedFieldId) <= 0}
+ className="cf-btn-outline size-8.5 flex items-center justify-center disabled:opacity-30"
+ >
+
+
+ {
+ const idx = fields.findIndex((f) => f.id === selectedFieldId);
+ if (idx < fields.length - 1) setSelectedFieldId(fields[idx + 1].id);
+ }}
+ disabled={fields.findIndex((f) => f.id === selectedFieldId) >= fields.length - 1}
+ className="cf-btn-outline size-8.5 flex items-center justify-center disabled:opacity-30"
+ >
+
+
+
+
+
+ {/* List of answers */}
+ {selectedQuestionSummary && (
+
+
+
+ {selectedQuestionSummary.field.label || "Untitled Question"}
+
+
+ {selectedQuestionSummary.answers.length} {selectedQuestionSummary.answers.length === 1 ? "response" : "responses"}
+
+
+
+
+ {selectedQuestionSummary.answers.length === 0 ? (
+
No answers submitted for this question.
+ ) : (
+ selectedQuestionSummary.answers.map((ans, idx) => (
+
+
+ {selectedQuestionSummary.field.type === "FILE_UPLOAD" ? (
+
+ ) : Array.isArray(ans.value) ? (
+ ans.value.join(", ")
+ ) : (
+ String(ans.value)
+ )}
+
+
+
+
+ {ans.respondent}
+
+
+
+ {new Date(ans.submittedAt).toLocaleString()}
+
+
+
+ ))
+ )}
+
+
+ )}
+ >
+ )}
+
+ )}
+
+ {/* ── 3. INDIVIDUAL VIEW ── */}
+ {subTab === "responses" && (
+
+ {submissions.length === 0 ? (
+
+
+
No submissions yet
+
Waiting for responses
+
+ ) : (
+
+ {submissions.map((sub) => (
+
+
+
+ {sub.respondentEmail || "Anonymous"}
+
+
+ {new Date(sub.createdAt).toLocaleString()}
+ ·
+ {sub.deviceType || "Desktop"}
+
+
+
setViewingSub(sub)}
+ title="View response details"
+ className="cf-btn-outline size-8 shrink-0 ml-3 flex items-center justify-center"
+ >
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {/* ── Details Popup Modal ── */}
+ {viewingSub && (
+
+
+
+
+
+ Response Details
+ .
+
+
+ {viewingSub.respondentEmail || "Anonymous"} · {new Date(viewingSub.createdAt).toLocaleString()}
+
+
+
setViewingSub(null)}
+ className="cf-btn-outline size-8 flex items-center justify-center"
+ title="Close"
+ >
+
+
+
+
+
+ {fields.map((field) => {
+ const valObj = viewingSub.values?.find((v: any) => v.formFieldId === field.id);
+ const val = valObj?.value;
+ let display: React.ReactNode =
No answer submitted ;
+
+ if (val !== undefined && val !== null && val !== "") {
+ if (field.type === "FILE_UPLOAD") {
+ display =
;
+ } else if (Array.isArray(val)) {
+ display = val.join(", ");
+ } else {
+ display = String(val);
+ }
+ }
+
+ return (
+
+
+ {field.label || "Untitled Question"}
+
+
{display}
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/hooks/api/analytics/index.ts b/apps/web/hooks/api/analytics/index.ts
index 0438fed..e5aecd7 100644
--- a/apps/web/hooks/api/analytics/index.ts
+++ b/apps/web/hooks/api/analytics/index.ts
@@ -1,30 +1,10 @@
import { trpc } from "~/trpc/client";
-export const useGetFormAnalytics = (formId: string) => {
- const {
- data: analytics,
- error,
- isLoading,
- isError,
- isSuccess,
- refetch,
- } = trpc.analytics.getFormAnalytics.useQuery(
- { formId },
- {
- enabled: !!formId && formId.length === 36,
- staleTime: 30_000,
- refetchOnWindowFocus: false,
- },
- );
-
- return { analytics, error, isLoading, isError, isSuccess, refetch };
-};
-
export const useGetSubmissions = (formId: string) => {
- const PAGE_SIZE = 100;
+ const PAGE_SIZE = 200;
const enabled = !!formId && formId.length === 36;
- const result = trpc.analytics.getSubmissions.useInfiniteQuery(
+ const result = trpc.form.getSubmissions.useInfiniteQuery(
{ formId, limit: PAGE_SIZE },
{
enabled,
@@ -53,28 +33,3 @@ export const useGetSubmissions = (formId: string) => {
isFetchingNextPage: result.isFetchingNextPage,
};
};
-
-export const useRecordFieldAnswer = () => {
- const {
- mutate: recordFieldAnswer,
- mutateAsync: recordFieldAnswerAsync,
- isPending,
- } = trpc.analytics.recordFieldAnswer.useMutation();
-
- return { recordFieldAnswer, recordFieldAnswerAsync, isPending };
-};
-
-export const useGetDetailedAnalytics = (formId: string) => {
- const {
- data: detailedAnalytics,
- error,
- isLoading,
- isError,
- refetch,
- } = trpc.analytics.getDetailedAnalytics.useQuery(
- { formId },
- { enabled: !!formId && formId.length === 36, retry: false },
- );
-
- return { detailedAnalytics, error, isLoading, isError, refetch };
-};
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index a9a4803..66ffd94 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -12,12 +12,11 @@ import https from "node:https";
import { logger } from "@repo/logger";
import { closeRedis, isRedisConfigured } from "@repo/redis";
-import { createAnalyticsWorker, createUploadWorker, queueEnv } from "@repo/queue";
+import { createUploadWorker, queueEnv } from "@repo/queue";
import { env, isCloudinaryConfigured } from "./env";
import { cloudinaryStorage } from "./storage";
import { createUploadProcessor } from "./processors/upload";
-import { processFieldAnswers } from "./processors/analytics";
const agentOptions = { keepAlive: true, maxSockets: 256, maxFreeSockets: 32 };
http.globalAgent = new http.Agent(agentOptions);
@@ -32,13 +31,13 @@ async function main() {
if (!isCloudinaryConfigured()) {
logger.warn(
"[worker] Cloudinary is not configured. Upload jobs will be failed with a clear " +
- "message; analytics jobs are unaffected. Set CLOUDINARY_CLOUD_NAME, " +
- "CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET to enable file storage.",
+ "message. Set CLOUDINARY_CLOUD_NAME, " +
+ "CLOUDINARY_API_KEY and CLOUDINARY_API_SECRET to enable file storage." +
+ " If this is development, you can proceed without it."
);
}
const uploadWorker = createUploadWorker(createUploadProcessor(cloudinaryStorage));
- const analyticsWorker = createAnalyticsWorker(processFieldAnswers);
uploadWorker.on("failed", (job, err) => {
logger.error(`[worker:upload] job ${job?.id ?? "unknown"} failed: ${err.message}`);
@@ -47,16 +46,8 @@ async function main() {
logger.error(`[worker:upload] worker error: ${err.message}`);
});
- analyticsWorker.on("failed", (job, err) => {
- logger.error(`[worker:analytics] job ${job?.id ?? "unknown"} failed: ${err.message}`);
- });
- analyticsWorker.on("error", (err) => {
- logger.error(`[worker:analytics] worker error: ${err.message}`);
- });
-
logger.info(
- `[worker] listening — uploads x${queueEnv.UPLOAD_WORKER_CONCURRENCY}, ` +
- `analytics x${queueEnv.ANALYTICS_WORKER_CONCURRENCY} (${env.NODE_ENV})`,
+ `[worker] listening — uploads x${queueEnv.UPLOAD_WORKER_CONCURRENCY} (${env.NODE_ENV})`
);
let shuttingDown = false;
@@ -74,7 +65,7 @@ async function main() {
forceExit.unref();
try {
- await Promise.allSettled([uploadWorker.close(), analyticsWorker.close()]);
+ await Promise.allSettled([uploadWorker.close()]);
await closeRedis();
logger.info("[worker] stopped cleanly");
process.exit(0);
diff --git a/apps/worker/src/processors/analytics.ts b/apps/worker/src/processors/analytics.ts
deleted file mode 100644
index 9f05234..0000000
--- a/apps/worker/src/processors/analytics.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import type { Job } from "bullmq";
-import { logger } from "@repo/logger";
-import { queueEnv, type RecordFieldAnswersJob } from "@repo/queue";
-
-import { analyticsService } from "../services";
-
-export async function processFieldAnswers(job: Job): Promise {
- const answers = job.data.answers ?? [];
- if (answers.length === 0) return;
-
- const written = await analyticsService.recordFieldAnswersBatch(
- answers,
- queueEnv.ANALYTICS_INSERT_CHUNK,
- );
-
- if (written < answers.length) {
- logger.warn(
- `[worker:analytics] wrote ${written}/${answers.length} field answers — ` +
- `the rest referenced forms or fields that no longer exist`,
- );
- return;
- }
-
- logger.debug(`[worker:analytics] wrote ${written} field answers`);
-}
diff --git a/apps/worker/src/services.ts b/apps/worker/src/services.ts
index 7bb57df..37fecf3 100644
--- a/apps/worker/src/services.ts
+++ b/apps/worker/src/services.ts
@@ -1,4 +1,2 @@
-import AnalyticsService from "@repo/services/analytics";
import FormUploadService from "@repo/services/form-upload";
-export const analyticsService = new AnalyticsService();
export const formUploadService = new FormUploadService();
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 0000000..4f7ca51
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,96 @@
+services:
+ postgres:
+ image: postgres:15
+ container_name: canvasflow-postgres-prod
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ POSTGRES_DB: ${POSTGRES_DB:-dev}
+ command:
+ - postgres
+ - -c
+ - max_connections=300
+ ports:
+ - "5432:5432"
+ volumes:
+ - pg_data_prod:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready --host=127.0.0.1 -p 5432 -U $${POSTGRES_USER:-postgres} -d $${POSTGRES_DB:-dev}"]
+ interval: 3s
+ timeout: 5s
+ retries: 15
+ start_period: 10s
+
+ redis:
+ image: redis:7-alpine
+ container_name: canvasflow-redis-prod
+ restart: unless-stopped
+ command:
+ - redis-server
+ - --maxmemory
+ - 512mb
+ - --maxmemory-policy
+ - noeviction
+ - --appendonly
+ - "no"
+ ports:
+ - "6379:6379"
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 3s
+ timeout: 5s
+ retries: 15
+ start_period: 3s
+
+ api:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.api
+ container_name: canvasflow-api-prod
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ NODE_ENV: production
+ PORT: 8000
+ DATABASE_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-dev}
+ REDIS_URL: redis://redis:6379
+ BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+ BETTER_AUTH_URL: ${BETTER_AUTH_URL}
+ GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
+ GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
+ GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID}
+ GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET}
+ IMAGEKIT_PUBLIC_KEY: ${IMAGEKIT_PUBLIC_KEY}
+ IMAGEKIT_PRIVATE_KEY: ${IMAGEKIT_PRIVATE_KEY}
+ IMAGEKIT_URL_ENDPOINT: ${IMAGEKIT_URL_ENDPOINT}
+ BASE_URL: ${BASE_URL}
+ TRUSTED_ORIGINS: ${TRUSTED_ORIGINS}
+ RATE_LIMIT_PUBLIC_WRITE_MAX: ${RATE_LIMIT_PUBLIC_WRITE_MAX}
+ RATE_LIMIT_AUTH_MAX: ${RATE_LIMIT_AUTH_MAX}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
+ web:
+ build:
+ context: .
+ dockerfile: docker/Dockerfile.web
+ args:
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
+ container_name: canvasflow-web-prod
+ restart: unless-stopped
+ ports:
+ - "3000:3000"
+ environment:
+ NODE_ENV: production
+ PORT: 3000
+ NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
+ depends_on:
+ - api
+
+volumes:
+ pg_data_prod:
diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api
new file mode 100644
index 0000000..d227040
--- /dev/null
+++ b/docker/Dockerfile.api
@@ -0,0 +1,22 @@
+FROM node:22-alpine AS base
+RUN npm install -g pnpm@9.0.0
+WORKDIR /app
+
+FROM base AS builder
+COPY . .
+RUN pnpm install --frozen-lockfile
+RUN pnpm --filter @repo/api build
+RUN pnpm --filter @repo/api deploy --prod /prod/api
+RUN pnpm --filter @repo/database deploy --prod /prod/db
+COPY packages/database/drizzle /prod/db/drizzle
+COPY packages/database/migrate.mjs /prod/db/migrate.mjs
+
+FROM base AS runner
+COPY --from=builder /prod/api /app/api
+COPY --from=builder /prod/db /app/db
+
+EXPOSE 8000
+ENV NODE_ENV=production
+ENV PORT=8000
+
+CMD cd /app/db && node migrate.mjs && cd /app/api && node dist/index.js
diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web
new file mode 100644
index 0000000..ec249a6
--- /dev/null
+++ b/docker/Dockerfile.web
@@ -0,0 +1,23 @@
+FROM node:22-alpine AS base
+RUN npm install -g pnpm@9.0.0
+WORKDIR /app
+
+FROM base AS builder
+COPY . .
+ARG NEXT_PUBLIC_API_URL
+ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
+RUN pnpm install --frozen-lockfile
+RUN pnpm --filter web build
+
+FROM base AS runner
+ENV NODE_ENV=production
+ENV PORT=3000
+ENV HOSTNAME="0.0.0.0"
+
+COPY --from=builder /app/apps/web/public /app/apps/web/public
+COPY --from=builder /app/apps/web/.next/standalone /app
+COPY --from=builder /app/apps/web/.next/static /app/apps/web/.next/static
+
+EXPOSE 3000
+
+CMD node apps/web/server.js
diff --git a/packages/database/models/form-field-view.ts b/packages/database/models/form-field-view.ts
deleted file mode 100644
index 9a1fef4..0000000
--- a/packages/database/models/form-field-view.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { index, jsonb, pgTable, uuid, timestamp } from "drizzle-orm/pg-core";
-import { formsTable } from "./form";
-import { formFieldsTable } from "./form-field";
-
-export const formFieldViewsTable = pgTable(
- "form_field_views",
- {
- id: uuid("id").primaryKey().defaultRandom(),
- formId: uuid("form_id")
- .references(() => formsTable.id, { onDelete: "cascade" })
- .notNull(),
- fieldId: uuid("field_id")
- .references(() => formFieldsTable.id, { onDelete: "cascade" })
- .notNull(),
- value: jsonb("value"),
- createdAt: timestamp("created_at").defaultNow().notNull(),
- },
- (table) => ({
- formFieldIdx: index("form_field_views_form_field_idx").on(table.formId, table.fieldId),
- formCreatedIdx: index("form_field_views_created_idx").on(table.formId, table.createdAt),
- }),
-);
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index c3a1835..7f728bf 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -6,6 +6,5 @@ export * from "./models/form-logic";
export * from "./models/form-submission";
export * from "./models/form-upload";
export * from "./models/form-draft";
-export * from "./models/form-field-view";
export * from "./models/form-collaborator";
export * from "./models/feedback";
diff --git a/packages/queue/index.ts b/packages/queue/index.ts
index b9855f6..faf564e 100644
--- a/packages/queue/index.ts
+++ b/packages/queue/index.ts
@@ -1,16 +1,12 @@
import "dotenv/config";
-import { Queue, Worker, type ConnectionOptions, type JobsOptions, type Processor } from "bullmq";
+import { Queue, Worker, type ConnectionOptions, type Processor } from "bullmq";
import { blockingConnection, isRedisConfigured, redisEnv } from "@repo/redis";
import { env as queueEnv } from "./env";
import {
- QUEUE_ANALYTICS,
QUEUE_UPLOADS,
JOB_PROCESS_UPLOAD,
- JOB_RECORD_FIELD_ANSWERS,
- type FieldAnswer,
type ProcessUploadJob,
- type RecordFieldAnswersJob,
} from "./jobs";
export * from "./jobs";
@@ -56,7 +52,6 @@ function getQueue(name: string): Queue {
}
export const uploadsQueue = () => getQueue(QUEUE_UPLOADS);
-export const analyticsQueue = () => getQueue(QUEUE_ANALYTICS);
/* ── Producers ─────────────────────────────────────────────────────────── */
@@ -71,50 +66,6 @@ export async function enqueueUpload(payload: ProcessUploadJob): Promise {
});
}
-let answerBuffer: FieldAnswer[] = [];
-let flushTimer: NodeJS.Timeout | null = null;
-
-async function flushAnswerBuffer(): Promise {
- if (flushTimer) {
- clearTimeout(flushTimer);
- flushTimer = null;
- }
- if (answerBuffer.length === 0) return;
- const batch = answerBuffer;
- answerBuffer = [];
-
- try {
- await analyticsQueue().add(
- JOB_RECORD_FIELD_ANSWERS,
- { answers: batch } satisfies RecordFieldAnswersJob,
- {
- priority: 10,
- attempts: 2,
- },
- );
- } catch (err) {
- console.error(
- `[queue:analytics] dropped ${batch.length} field answer(s):`,
- err instanceof Error ? err.message : err,
- );
- }
-}
-export function enqueueFieldAnswer(answer: FieldAnswer): void {
- if (!isQueueAvailable()) return;
-
- answerBuffer.push(answer);
-
- if (answerBuffer.length >= queueEnv.ANALYTICS_BATCH_MAX) {
- void flushAnswerBuffer();
- return;
- }
-
- if (!flushTimer) {
- flushTimer = setTimeout(() => void flushAnswerBuffer(), queueEnv.ANALYTICS_BATCH_MS);
- flushTimer.unref?.();
- }
-}
-
export function createUploadWorker(processor: Processor): Worker {
return new Worker(QUEUE_UPLOADS, processor, {
connection: blockingConnection() as unknown as ConnectionOptions,
@@ -126,19 +77,7 @@ export function createUploadWorker(processor: Processor): Work
});
}
-export function createAnalyticsWorker(processor: Processor): Worker {
- return new Worker(QUEUE_ANALYTICS, processor, {
- connection: blockingConnection() as unknown as ConnectionOptions,
- prefix: bullPrefix,
- concurrency: queueEnv.ANALYTICS_WORKER_CONCURRENCY,
- lockDuration: 30_000,
- maxStalledCount: 2,
- });
-}
-
export async function drainProducers(): Promise {
- await flushAnswerBuffer();
-
await Promise.allSettled([...queues.values()].map((queue) => queue.close()));
queues.clear();
diff --git a/packages/queue/jobs.ts b/packages/queue/jobs.ts
index b25d88c..3b123e4 100644
--- a/packages/queue/jobs.ts
+++ b/packages/queue/jobs.ts
@@ -1,8 +1,6 @@
export const QUEUE_UPLOADS = "uploads";
-export const QUEUE_ANALYTICS = "analytics";
export const JOB_PROCESS_UPLOAD = "process-upload";
-export const JOB_RECORD_FIELD_ANSWERS = "record-field-answers";
export interface ProcessUploadJob {
uploadId: string;
@@ -11,13 +9,3 @@ export interface ProcessUploadJob {
mimeType: string;
originalName: string;
}
-
-export interface FieldAnswer {
- formId: string;
- fieldId: string;
- value: unknown;
-}
-
-export interface RecordFieldAnswersJob {
- answers: FieldAnswer[];
-}
diff --git a/packages/services/analytics/index.ts b/packages/services/analytics/index.ts
deleted file mode 100644
index b7d2856..0000000
--- a/packages/services/analytics/index.ts
+++ /dev/null
@@ -1,567 +0,0 @@
-import { db, eq, and, desc, gte, lt, count } from "@repo/database";
-import { enqueueFieldAnswer, isQueueAvailable } from "@repo/queue";
-import { formsTable } from "@repo/database/models/form";
-import { formFieldsTable } from "@repo/database/models/form-field";
-import { formSubmissionsTable } from "@repo/database/models/form-submission";
-import { formFieldViewsTable } from "@repo/database/models/form-field-view";
-import { requireViewer } from "../form";
-
-import {
- getFormAnalyticsInput,
- type GetFormAnalyticsInputType,
- getSubmissionsListInput,
- type GetSubmissionsListInputType,
- getDetailedAnalyticsInput,
- type GetDetailedAnalyticsInputType,
- recordFieldAnswerInput,
- type RecordFieldAnswerInputType,
-} from "./model";
-
-const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
-
-class AnalyticsService {
- public async getFormAnalytics(payload: GetFormAnalyticsInputType & { ownerId: string }) {
- const { formId } = await getFormAnalyticsInput.parseAsync(payload);
- const { ownerId: userId } = payload;
-
- await requireViewer(formId, userId);
-
- const thirtyDaysAgo = new Date();
- thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 29);
- thirtyDaysAgo.setHours(0, 0, 0, 0);
-
- // Run all DB queries in parallel
- const [totalResponseRow, deviceRows, recentTimestamps] = await Promise.all([
- // Total submissions (all time)
- db
- .select({ value: count() })
- .from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, formId)),
-
- // Device breakdown from submissions
- db
- .select({ deviceType: formSubmissionsTable.deviceType, value: count() })
- .from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, formId))
- .groupBy(formSubmissionsTable.deviceType),
-
- // Submission timestamps for last 30 days (no values jsonb — just timestamps)
- db
- .select({ createdAt: formSubmissionsTable.createdAt })
- .from(formSubmissionsTable)
- .where(
- and(
- eq(formSubmissionsTable.formId, formId),
- gte(formSubmissionsTable.createdAt, thirtyDaysAgo),
- ),
- ),
- ]);
-
- const totalResponses = Number(totalResponseRow[0]?.value ?? 0);
- const deviceMap: Record = { desktop: 0, mobile: 0, tablet: 0 };
- deviceRows.forEach((r) => {
- if (!r.deviceType) return;
- const n = Number(r.value);
- const dev = r.deviceType.toLowerCase();
- if (dev.includes("mobile")) deviceMap["mobile"] = (deviceMap["mobile"] ?? 0) + n;
- else if (dev.includes("tablet")) deviceMap["tablet"] = (deviceMap["tablet"] ?? 0) + n;
- else deviceMap["desktop"] = (deviceMap["desktop"] ?? 0) + n;
- });
- const deviceBreakdown = Object.entries(deviceMap).map(([device, cnt]) => ({
- device,
- count: cnt,
- }));
-
- // ─── Daily trends (last 30 days, zero-filled) ─────────────────────────
- const dailyMap: Record = {};
- for (let i = 29; i >= 0; i--) {
- const d = new Date();
- d.setDate(d.getDate() - i);
- const key = d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
- dailyMap[key] = 0;
- }
- recentTimestamps.forEach((s) => {
- const key = new Date(s.createdAt).toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- });
- if (dailyMap[key] !== undefined) dailyMap[key]++;
- });
- const dailyTrends = Object.entries(dailyMap).map(([date, cnt]) => ({ date, count: cnt }));
-
- // ─── Peak day of week ─────────────────────────────────────────────────
- const dowMap: Record = {};
- for (let d = 0; d < 7; d++) dowMap[d] = 0;
- recentTimestamps.forEach((s) => {
- const dow = new Date(s.createdAt).getDay();
- dowMap[dow] = (dowMap[dow] ?? 0) + 1;
- });
- const peakDowEntry = Object.entries(dowMap).reduce(
- (best, [d, cnt]) => (cnt > best.count ? { day: Number(d), count: cnt } : best),
- { day: 0, count: 0 },
- );
- const peakDay = peakDowEntry.count > 0 ? (DAYS[peakDowEntry.day] ?? null) : null;
-
- // ─── Averages ─────────────────────────────────────────────────────────
- const avgSubmissionsPerDay = parseFloat((recentTimestamps.length / 30).toFixed(1));
- const avgSubmissionsPerWeek = parseFloat((recentTimestamps.length / (30 / 7)).toFixed(1));
-
- return {
- totalResponses,
- deviceBreakdown,
- dailyTrends,
- peakDay,
- avgSubmissionsPerDay,
- avgSubmissionsPerWeek,
- };
- }
- public async getDetailedAnalytics(payload: GetDetailedAnalyticsInputType & { ownerId: string }) {
- const { formId } = await getDetailedAnalyticsInput.parseAsync(payload);
- const { ownerId: userId } = payload;
-
- await requireViewer(formId, userId);
-
- const formRows = await db
- .select({
- id: formsTable.id,
- publishedAt: formsTable.publishedAt,
- })
- .from(formsTable)
- .where(eq(formsTable.id, formId));
-
- if (!formRows[0]) throw new Error("Form not found");
- const form = formRows[0];
-
- const now = new Date();
- const ago30 = new Date(now);
- ago30.setDate(now.getDate() - 30);
- ago30.setHours(0, 0, 0, 0);
- const ago60 = new Date(now);
- ago60.setDate(now.getDate() - 60);
- ago60.setHours(0, 0, 0, 0);
- const ago90 = new Date(now);
- ago90.setDate(now.getDate() - 90);
- ago90.setHours(0, 0, 0, 0);
-
- const [
- fields,
- allSubmissions,
- count30d,
- count60d,
- count90d,
- referrerRows,
- utmRows,
- fieldViewRows,
- rawFieldViewRows,
- ] = await Promise.all([
- db
- .select({
- id: formFieldsTable.id,
- label: formFieldsTable.label,
- type: formFieldsTable.type,
- options: formFieldsTable.options,
- })
- .from(formFieldsTable)
- .where(eq(formFieldsTable.formId, formId)),
-
- db
- .select({
- values: formSubmissionsTable.values,
- createdAt: formSubmissionsTable.createdAt,
- timeSpentMs: formSubmissionsTable.timeSpentMs,
- })
- .from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, formId))
- .orderBy(desc(formSubmissionsTable.createdAt))
- .limit(5000), // safety cap — enough for any real analytics calculation
-
- db
- .select({ value: count() })
- .from(formSubmissionsTable)
- .where(
- and(eq(formSubmissionsTable.formId, formId), gte(formSubmissionsTable.createdAt, ago30)),
- ),
-
- db
- .select({ value: count() })
- .from(formSubmissionsTable)
- .where(
- and(eq(formSubmissionsTable.formId, formId), gte(formSubmissionsTable.createdAt, ago60)),
- ),
-
- db
- .select({ value: count() })
- .from(formSubmissionsTable)
- .where(
- and(eq(formSubmissionsTable.formId, formId), gte(formSubmissionsTable.createdAt, ago90)),
- ),
-
- // Top referrers from submissions
- db
- .select({ referrer: formSubmissionsTable.referrer, value: count() })
- .from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, formId))
- .groupBy(formSubmissionsTable.referrer),
-
- // UTM source breakdown from submissions
- db
- .select({ utmSource: formSubmissionsTable.utmSource, value: count() })
- .from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, formId))
- .groupBy(formSubmissionsTable.utmSource),
-
- // Per-field answer counts from form_field_views (the real completion source)
- db
- .select({ fieldId: formFieldViewsTable.fieldId, value: count() })
- .from(formFieldViewsTable)
- .where(eq(formFieldViewsTable.formId, formId))
- .groupBy(formFieldViewsTable.fieldId),
-
- // Raw field view rows with values — used for question distribution
- db
- .select({
- fieldId: formFieldViewsTable.fieldId,
- value: formFieldViewsTable.value,
- createdAt: formFieldViewsTable.createdAt,
- })
- .from(formFieldViewsTable)
- .where(eq(formFieldViewsTable.formId, formId))
- .orderBy(desc(formFieldViewsTable.createdAt)),
- ]);
- // ─── Day-of-week breakdown ─────────────────────────────────────────────
- const SHORT_DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
- const dowMap: Record = {};
- for (let d = 0; d < 7; d++) dowMap[d] = 0;
- allSubmissions.forEach((s) => {
- const dow = new Date(s.createdAt).getDay();
- dowMap[dow] = (dowMap[dow] ?? 0) + 1;
- });
- const dowBreakdown = SHORT_DAYS.map((day, i) => ({ day, count: dowMap[i] ?? 0 }));
-
- // ─── Response velocity (first 24h after publish) ───────────────────────
- let velocityFirst24h = 0;
- if (form.publishedAt) {
- const publishedAt = new Date(form.publishedAt);
- const cutoff = new Date(publishedAt.getTime() + 24 * 60 * 60 * 1000);
- velocityFirst24h = allSubmissions.filter((s) => {
- const t = new Date(s.createdAt);
- return t >= publishedAt && t <= cutoff;
- }).length;
- }
-
- const TEXT_TYPES = ["TEXT", "TEXTAREA", "EMAIL", "NUMBER", "PHONE", "URL", "DATE", "TIME"];
- const CHOICE_TYPES_SET = ["SELECT", "RADIO", "CHECKBOX"];
-
- const totalSubmissions = allSubmissions.length;
-
- const fieldViewsByField = new Map>();
- rawFieldViewRows.forEach((r) => {
- if (!fieldViewsByField.has(r.fieldId)) fieldViewsByField.set(r.fieldId, []);
- fieldViewsByField.get(r.fieldId)!.push({ value: r.value, createdAt: r.createdAt });
- });
-
- const questionDistribution = fields.map((field) => {
- // Use rawFieldViewRows if available, otherwise fall back to allSubmissions
- const fieldViewEntries = fieldViewsByField.get(field.id);
- const useFieldViews = fieldViewEntries && fieldViewEntries.length > 0;
-
- // Build the answers array from whichever source is available
- const answers: Array<{ value: unknown }> = useFieldViews
- ? fieldViewEntries!.filter(
- (e) => e.value !== null && e.value !== undefined && e.value !== "",
- )
- : allSubmissions
- .map((s) =>
- (s.values as Array<{ formFieldId: string; value: unknown }>).find(
- (v) => v.formFieldId === field.id,
- ),
- )
- .filter(
- (a): a is { formFieldId: string; value: unknown } =>
- a !== undefined && a.value !== null && a.value !== "",
- );
-
- const totalAnswered = useFieldViews
- ? (fieldViewsByField.get(field.id)?.length ?? 0)
- : answers.length;
-
- if (CHOICE_TYPES_SET.includes(field.type)) {
- const valueCounts: Record = {};
- answers.forEach((a) => {
- const vals = Array.isArray(a.value) ? a.value : [a.value];
- vals.forEach((v) => {
- const s = String(v);
- valueCounts[s] = (valueCounts[s] ?? 0) + 1;
- });
- });
- const optionCounts = Object.entries(valueCounts)
- .sort((a, b) => b[1] - a[1])
- .map(([value, cnt]) => ({
- value,
- count: cnt,
- percent: answers.length > 0 ? parseFloat(((cnt / answers.length) * 100).toFixed(1)) : 0,
- }));
- return {
- fieldId: field.id,
- fieldLabel: field.label,
- fieldType: field.type,
- totalAnswered,
- optionCounts,
- };
- } else if (field.type === "RATING") {
- const nums = answers.map((a) => Number(a.value)).filter((n) => !isNaN(n));
- const averageRating =
- nums.length > 0
- ? parseFloat((nums.reduce((s, n) => s + n, 0) / nums.length).toFixed(1))
- : 0;
- const ratingCounts: Record = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
- nums.forEach((n) => {
- const clamped = Math.min(5, Math.max(1, Math.round(n)));
- ratingCounts[clamped] = (ratingCounts[clamped] ?? 0) + 1;
- });
- const ratingDistribution = [1, 2, 3, 4, 5].map((r) => ({
- rating: r,
- count: ratingCounts[r] ?? 0,
- percent:
- nums.length > 0
- ? parseFloat((((ratingCounts[r] ?? 0) / nums.length) * 100).toFixed(1))
- : 0,
- }));
- return {
- fieldId: field.id,
- fieldLabel: field.label,
- fieldType: field.type,
- totalAnswered,
- averageRating,
- ratingDistribution,
- };
- } else if (field.type === "TOGGLE") {
- const yes = answers.filter((a) => a.value === true || a.value === "true").length;
- const no = answers.filter((a) => a.value === false || a.value === "false").length;
- return {
- fieldId: field.id,
- fieldLabel: field.label,
- fieldType: field.type,
- totalAnswered,
- toggleCounts: { yes, no },
- };
- } else if (TEXT_TYPES.includes(field.type)) {
- const textSamples = answers
- .slice(0, 5)
- .map((a) => String(a.value))
- .filter((s) => s.trim().length > 0);
- return {
- fieldId: field.id,
- fieldLabel: field.label,
- fieldType: field.type,
- totalAnswered,
- textSamples,
- };
- } else {
- return { fieldId: field.id, fieldLabel: field.label, fieldType: field.type, totalAnswered };
- }
- });
-
- // ─── Median response time (minutes from publishedAt to submission) ─────
- let medianResponseTime: number | null = null;
- if (form.publishedAt && allSubmissions.length > 0) {
- const publishedAt = new Date(form.publishedAt);
- const deltas = allSubmissions
- .map((s) => (new Date(s.createdAt).getTime() - publishedAt.getTime()) / 60000)
- .filter((d) => d >= 0)
- .sort((a, b) => a - b);
- if (deltas.length > 0) {
- const mid = Math.floor(deltas.length / 2);
- medianResponseTime =
- deltas.length % 2 === 0
- ? parseFloat(((deltas[mid - 1]! + deltas[mid]!) / 2).toFixed(1))
- : parseFloat(deltas[mid]!.toFixed(1));
- }
- }
-
- // ─── Returning rate ────────────────────────────────────────────────────
- const emailField =
- fields.find((f) => f.type === "EMAIL") ?? fields.find((f) => TEXT_TYPES.includes(f.type));
- let returningRate = 0;
- if (emailField && totalSubmissions > 0) {
- const valueCounts: Record = {};
- allSubmissions.forEach((s) => {
- const entry = (s.values as Array<{ formFieldId: string; value: unknown }>).find(
- (v) => v.formFieldId === emailField.id,
- );
- if (entry?.value) {
- const key = String(entry.value).trim().toLowerCase();
- if (key) valueCounts[key] = (valueCounts[key] ?? 0) + 1;
- }
- });
- const duplicateCount = Object.values(valueCounts)
- .filter((c) => c > 1)
- .reduce((sum, c) => sum + c, 0);
- returningRate = parseFloat(((duplicateCount / totalSubmissions) * 100).toFixed(1));
- }
-
- // ─── Peak day ──────────────────────────────────────────────────────────
- const fullDayNames = [
- "Sunday",
- "Monday",
- "Tuesday",
- "Wednesday",
- "Thursday",
- "Friday",
- "Saturday",
- ];
- const peakEntry = dowBreakdown.reduce(
- (best, entry, i) => (entry.count > best.count ? { count: entry.count, idx: i } : best),
- { count: 0, idx: 0 },
- );
- const peakDay = peakEntry.count > 0 ? (fullDayNames[peakEntry.idx] ?? null) : null;
- const fieldViewMap = new Map(
- fieldViewRows.map((r) => [r.fieldId, Number(r.value)]),
- );
- const maxFieldInteractions = fieldViewRows.reduce((m, r) => Math.max(m, Number(r.value)), 0);
- const denominator = maxFieldInteractions > 0 ? maxFieldInteractions : totalSubmissions;
-
- const fieldCompletionRates = fields.map((field) => {
- const answeredCount = fieldViewMap.get(field.id) ?? 0;
- const rate =
- denominator > 0 ? parseFloat(((answeredCount / denominator) * 100).toFixed(1)) : 0;
- return { fieldId: field.id, fieldLabel: field.label, rate };
- });
-
- const timings = allSubmissions
- .map((s) => s.timeSpentMs)
- .filter((t): t is number => t !== null && t !== undefined && t > 0);
- const avgTimeSpentMs =
- timings.length > 0 ? Math.round(timings.reduce((s, t) => s + t, 0) / timings.length) : null;
-
- const topReferrers = referrerRows
- .filter((r) => r.referrer !== null && r.referrer !== undefined && r.referrer !== "")
- .map((r) => {
- let domain = r.referrer ?? "direct";
- try {
- domain = new URL(r.referrer ?? "").hostname.replace(/^www\./, "");
- } catch {}
- return { referrer: domain, count: Number(r.value) };
- })
- .reduce(
- (acc, cur) => {
- const existing = acc.find((a) => a.referrer === cur.referrer);
- if (existing) {
- existing.count += cur.count;
- } else {
- acc.push({ ...cur });
- }
- return acc;
- },
- [] as { referrer: string; count: number }[],
- )
- .sort((a, b) => b.count - a.count)
- .slice(0, 10);
-
- // Add "Direct" entry for null/empty referrers
- const directCount = referrerRows
- .filter((r) => !r.referrer || r.referrer === "")
- .reduce((s, r) => s + Number(r.value), 0);
- if (directCount > 0) topReferrers.push({ referrer: "Direct", count: directCount });
- topReferrers.sort((a, b) => b.count - a.count);
-
- // ─── UTM source breakdown ──────────────────────────────────────────────
- const utmSources = utmRows
- .filter((r) => r.utmSource !== null && r.utmSource !== undefined && r.utmSource !== "")
- .map((r) => ({ source: r.utmSource ?? "unknown", count: Number(r.value) }))
- .sort((a, b) => b.count - a.count)
- .slice(0, 10);
-
- return {
- dowBreakdown,
- trend30d: Number(count30d[0]?.value ?? 0),
- trend60d: Number(count60d[0]?.value ?? 0),
- trend90d: Number(count90d[0]?.value ?? 0),
- velocityFirst24h,
- questionDistribution,
- medianResponseTime,
- returningRate,
- peakDay,
- fieldCompletionRates,
- avgTimeSpentMs,
- topReferrers,
- utmSources,
- };
- }
-
- public async getSubmissionsList(payload: GetSubmissionsListInputType & { ownerId: string }) {
- const { formId, cursor, limit } = await getSubmissionsListInput.parseAsync(payload);
- const { ownerId: userId } = payload;
-
- await requireViewer(formId, userId);
-
- const pageSize = limit ?? 50;
- const cursorDate = cursor ? new Date(cursor) : null;
- const whereClause = cursorDate
- ? and(eq(formSubmissionsTable.formId, formId), lt(formSubmissionsTable.createdAt, cursorDate))
- : eq(formSubmissionsTable.formId, formId);
-
- const rows = await db
- .select()
- .from(formSubmissionsTable)
- .where(whereClause)
- .orderBy(desc(formSubmissionsTable.createdAt))
- .limit(pageSize + 1);
-
- const hasMore = rows.length > pageSize;
- const submissions = hasMore ? rows.slice(0, pageSize) : rows;
- const nextCursor = hasMore
- ? submissions[submissions.length - 1]!.createdAt.toISOString()
- : null;
-
- return { submissions, nextCursor };
- }
-
- public async recordFieldAnswer(payload: RecordFieldAnswerInputType) {
- const { formId, fieldId, value } = await recordFieldAnswerInput.parseAsync(payload);
-
- if (isQueueAvailable()) {
- enqueueFieldAnswer({ formId, fieldId, value: value ?? null });
- return { success: true };
- }
-
- await db.insert(formFieldViewsTable).values({ formId, fieldId, value: value ?? null });
- return { success: true };
- }
-
- public async recordFieldAnswersBatch(
- answers: Array<{ formId: string; fieldId: string; value: unknown }>,
- chunkSize = 500,
- ): Promise {
- if (answers.length === 0) return 0;
-
- let written = 0;
-
- for (let offset = 0; offset < answers.length; offset += chunkSize) {
- const chunk = answers.slice(offset, offset + chunkSize).map((answer) => ({
- formId: answer.formId,
- fieldId: answer.fieldId,
- value: answer.value ?? null,
- }));
-
- try {
- await db.insert(formFieldViewsTable).values(chunk);
- written += chunk.length;
- } catch (err: unknown) {
- const code =
- (err as { code?: string })?.code ?? (err as { cause?: { code?: string } })?.cause?.code;
-
- if (code !== "23503") throw err;
-
- const settled = await Promise.allSettled(
- chunk.map((row) => db.insert(formFieldViewsTable).values(row)),
- );
- written += settled.filter((result) => result.status === "fulfilled").length;
- }
- }
-
- return written;
- }
-}
-
-export default AnalyticsService;
diff --git a/packages/services/analytics/model.ts b/packages/services/analytics/model.ts
deleted file mode 100644
index a1ba070..0000000
--- a/packages/services/analytics/model.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import { z } from "zod";
-
-// ─── Inputs ──────────────────────────────────────────────────────────────────
-
-export const getFormAnalyticsInput = z.object({
- formId: z.string().uuid().describe("Form ID"),
-});
-export type GetFormAnalyticsInputType = z.infer;
-
-export const getSubmissionsListInput = z.object({
- formId: z.string().uuid().describe("Form ID"),
- cursor: z
- .string()
- .datetime()
- .optional()
- .nullable()
- .describe("ISO timestamp of the last submission from the previous page"),
- limit: z
- .number()
- .int()
- .min(1)
- .max(200)
- .optional()
- .default(50)
- .describe("Max submissions to return (default 50, max 200)"),
-});
-export type GetSubmissionsListInputType = z.infer;
-
-// ─── Shared sub-shapes ────────────────────────────────────────────────────────
-
-export const submissionValueOutput = z.object({
- formFieldId: z.string().uuid(),
- value: z.any(),
-});
-
-export const submissionOutput = z.object({
- id: z.string().uuid(),
- formId: z.string().uuid(),
- values: z.array(submissionValueOutput),
- createdAt: z.any(),
-});
-
-// ─── Analytics output ─────────────────────────────────────────────────────────
-
-export const getFormAnalyticsOutput = z.object({
- totalResponses: z.number(),
-
- deviceBreakdown: z.array(
- z.object({
- device: z.string(),
- count: z.number(),
- }),
- ),
-
- dailyTrends: z.array(
- z.object({
- date: z.string(),
- count: z.number(),
- }),
- ),
-
- peakDay: z.string().nullable(),
- avgSubmissionsPerDay: z.number(),
- avgSubmissionsPerWeek: z.number(),
-});
-export type GetFormAnalyticsOutputType = z.infer;
-
-export const getSubmissionsListOutput = z.object({
- submissions: z.array(submissionOutput),
- nextCursor: z.string().nullable(),
-});
-export type GetSubmissionsListOutputType = z.infer;
-
-export const recordFieldAnswerInput = z.object({
- formId: z.string().uuid(),
- fieldId: z.string().uuid(),
- value: z.any(), // the answer the visitor entered
-});
-export type RecordFieldAnswerInputType = z.infer;
-
-export const recordFieldAnswerOutput = z.object({
- success: z.boolean(),
-});
-export type RecordFieldAnswerOutputType = z.infer;
-
-export const getDetailedAnalyticsInput = z.object({
- formId: z.string().uuid(),
-});
-export type GetDetailedAnalyticsInputType = z.infer;
-
-export const questionDistributionOutput = z.object({
- fieldId: z.string().uuid(),
- fieldLabel: z.string(),
- fieldType: z.string(),
- totalAnswered: z.number(),
- optionCounts: z
- .array(
- z.object({
- value: z.string(),
- count: z.number(),
- percent: z.number(),
- }),
- )
- .optional(),
- averageRating: z.number().optional(),
- ratingDistribution: z
- .array(
- z.object({
- rating: z.number(),
- count: z.number(),
- percent: z.number(),
- }),
- )
- .optional(),
- // For TOGGLE: true/false counts
- toggleCounts: z.object({ yes: z.number(), no: z.number() }).optional(),
- // For TEXT/TEXTAREA/EMAIL/NUMBER/PHONE/URL/DATE/TIME: up to 5 recent samples
- textSamples: z.array(z.string()).optional(),
-});
-
-export const getDetailedAnalyticsOutput = z.object({
- dowBreakdown: z.array(z.object({ day: z.string(), count: z.number() })),
- trend30d: z.number(),
- trend60d: z.number(),
- trend90d: z.number(),
- velocityFirst24h: z.number(),
- questionDistribution: z.array(questionDistributionOutput),
- medianResponseTime: z.number().nullable(),
- returningRate: z.number(),
- peakDay: z.string().nullable(),
- fieldCompletionRates: z.array(
- z.object({
- fieldId: z.string(),
- fieldLabel: z.string(),
- rate: z.number(),
- }),
- ),
- avgTimeSpentMs: z.number().nullable(),
- topReferrers: z.array(z.object({ referrer: z.string(), count: z.number() })),
- utmSources: z.array(z.object({ source: z.string(), count: z.number() })),
-});
-export type GetDetailedAnalyticsOutputType = z.infer;
diff --git a/packages/services/form-submission/index.ts b/packages/services/form-submission/index.ts
index 3923f70..baa087f 100644
--- a/packages/services/form-submission/index.ts
+++ b/packages/services/form-submission/index.ts
@@ -1,6 +1,6 @@
import { db, eq, and, desc, lt } from "@repo/database";
import { formsTable } from "@repo/database/models/form";
-import { getFormBundle, invalidateFormCount } from "../form";
+import { getFormBundle, invalidateFormCount, requireViewer } from "../form";
import FormUploadService from "../form-upload";
import { assertRespondentAllowed, ALREADY_RESPONDED_ERROR, type Respondent } from "./access";
export * from "./access";
@@ -188,13 +188,7 @@ class FormSubmissionService {
public async getSubmissions(payload: GetSubmissionsInputType) {
const { formId, ownerId, cursor, limit } = await getSubmissionsInput.parseAsync(payload);
- const formResult = await db
- .select()
- .from(formsTable)
- .where(and(eq(formsTable.id, formId), eq(formsTable.ownerId, ownerId)));
- if (!formResult[0]) {
- throw new Error("Form not found or unauthorized");
- }
+ await requireViewer(formId, ownerId);
const cursorDate = cursor ? new Date(cursor) : null;
const whereClause = cursorDate
diff --git a/packages/services/form-submission/model.ts b/packages/services/form-submission/model.ts
index bdf55af..6903709 100644
--- a/packages/services/form-submission/model.ts
+++ b/packages/services/form-submission/model.ts
@@ -37,6 +37,10 @@ export const formSubmissionOutput = z.object({
id: z.string().uuid(),
formId: z.string().uuid(),
values: z.array(formSubmissionValueOutput),
+ visitorId: z.string().nullable().optional(),
+ respondentEmail: z.string().nullable().optional(),
+ timeSpentMs: z.number().nullable().optional(),
+ deviceType: z.string().nullable().optional(),
createdAt: z.any(),
});
export type FormSubmissionOutputType = z.infer;
diff --git a/packages/trpc/server/index.ts b/packages/trpc/server/index.ts
index 711f448..3153983 100644
--- a/packages/trpc/server/index.ts
+++ b/packages/trpc/server/index.ts
@@ -2,14 +2,12 @@ import { router } from "./trpc";
import { healthRouter } from "./routes/health/route";
import { formRouter } from "./routes/form/route";
-import { analyticsRouter } from "./routes/analytics/route";
import { userRouter } from "./routes/user/route";
import { feedbackRouter } from "./routes/feedback/route";
export const serverRouter = router({
health: healthRouter,
form: formRouter,
- analytics: analyticsRouter,
user: userRouter,
feedback: feedbackRouter,
});
diff --git a/packages/trpc/server/routes/analytics/model.ts b/packages/trpc/server/routes/analytics/model.ts
deleted file mode 100644
index 4e11c20..0000000
--- a/packages/trpc/server/routes/analytics/model.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export {
- getFormAnalyticsInput as getFormAnalyticsInputModel,
- getFormAnalyticsOutput as getFormAnalyticsOutputModel,
- getSubmissionsListInput as getSubmissionsListInputModel,
- getSubmissionsListOutput as getSubmissionsListOutputModel,
- getDetailedAnalyticsInput,
- getDetailedAnalyticsOutput,
- recordFieldAnswerInput as recordFieldAnswerInputModel,
- recordFieldAnswerOutput as recordFieldAnswerOutputModel,
-} from "@repo/services/analytics/model";
diff --git a/packages/trpc/server/routes/analytics/route.ts b/packages/trpc/server/routes/analytics/route.ts
deleted file mode 100644
index 449e23e..0000000
--- a/packages/trpc/server/routes/analytics/route.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import { authenticatedProcedure, publicProcedure, router } from "../../trpc";
-import { generatePath } from "../../utils/path-generator";
-import {
- getFormAnalyticsInputModel,
- getFormAnalyticsOutputModel,
- getSubmissionsListInputModel,
- getSubmissionsListOutputModel,
- getDetailedAnalyticsInput as getDetailedAnalyticsInputModel,
- getDetailedAnalyticsOutput as getDetailedAnalyticsOutputModel,
- recordFieldAnswerInputModel,
- recordFieldAnswerOutputModel,
-} from "./model";
-import { analyticsService } from "../../services";
-
-const TAGS = ["Analytics"];
-const getPath = generatePath("/analytics");
-
-export const analyticsRouter = router({
- // GET /analytics/getFormAnalytics/{formId}
- getFormAnalytics: authenticatedProcedure
- .meta({
- openapi: {
- method: "GET",
- path: getPath("/getFormAnalytics/{formId}"),
- tags: TAGS,
- protect: true,
- },
- })
- .input(getFormAnalyticsInputModel)
- .output(getFormAnalyticsOutputModel)
- .query(async ({ input, ctx }) => {
- return analyticsService.getFormAnalytics({
- formId: input.formId,
- ownerId: ctx.user.id,
- });
- }),
-
- // GET /analytics/getSubmissions/{formId}
- getSubmissions: authenticatedProcedure
- .meta({
- openapi: {
- method: "GET",
- path: getPath("/getSubmissions/{formId}"),
- tags: TAGS,
- protect: true,
- },
- })
- .input(getSubmissionsListInputModel)
- .output(getSubmissionsListOutputModel)
- .query(async ({ input, ctx }) => {
- return analyticsService.getSubmissionsList({
- formId: input.formId,
- ownerId: ctx.user.id,
- cursor: input.cursor ?? null,
- limit: input.limit,
- });
- }),
-
- // POST /analytics/recordFieldAnswer
- recordFieldAnswer: publicProcedure
- .meta({
- openapi: {
- method: "POST",
- path: getPath("/recordFieldAnswer"),
- tags: TAGS,
- protect: false,
- },
- })
- .input(recordFieldAnswerInputModel)
- .output(recordFieldAnswerOutputModel)
- .mutation(async ({ input }) => {
- return analyticsService.recordFieldAnswer(input);
- }),
-
- // GET /analytics/getDetailedAnalytics/{formId}
- getDetailedAnalytics: authenticatedProcedure
- .meta({
- openapi: {
- method: "GET",
- path: getPath("/getDetailedAnalytics/{formId}"),
- tags: TAGS,
- protect: true,
- },
- })
- .input(getDetailedAnalyticsInputModel)
- .output(getDetailedAnalyticsOutputModel)
- .query(async ({ input, ctx }) => {
- return analyticsService.getDetailedAnalytics({
- formId: input.formId,
- ownerId: ctx.user.id,
- });
- }),
-});
diff --git a/packages/trpc/server/routes/form/model.ts b/packages/trpc/server/routes/form/model.ts
index a45f09c..49c6866 100644
--- a/packages/trpc/server/routes/form/model.ts
+++ b/packages/trpc/server/routes/form/model.ts
@@ -102,6 +102,8 @@ export {
export {
submitFormInput as submitFormInputModel,
submitFormOutput as submitFormOutputModel,
+ getSubmissionsInput as getSubmissionsInputModel,
+ getSubmissionsOutput as getSubmissionsOutputModel,
} from "@repo/services/form-submission/model";
export {
diff --git a/packages/trpc/server/routes/form/route.ts b/packages/trpc/server/routes/form/route.ts
index 6545f88..15af436 100644
--- a/packages/trpc/server/routes/form/route.ts
+++ b/packages/trpc/server/routes/form/route.ts
@@ -23,6 +23,7 @@ import {
deleteFormOutputModel,
submitFormInputModel,
submitFormOutputModel,
+ getSubmissionsOutputModel,
listFormFieldsInputModel,
listFormFieldsOutputModel,
getDashboardStatsOutputModel,
@@ -555,4 +556,30 @@ export const formRouter = router({
.mutation(async ({ input, ctx }) => {
return formDraftService.deleteDraft({ ...input, userId: ctx.user.id });
}),
+
+ getSubmissions: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "GET",
+ path: getPath("/getSubmissions/{formId}"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(
+ z.object({
+ formId: z.string().uuid(),
+ cursor: z.string().datetime().optional().nullable(),
+ limit: z.number().int().min(1).max(200).optional().default(50),
+ }),
+ )
+ .output(getSubmissionsOutputModel)
+ .query(async ({ input, ctx }) => {
+ return formSubmissionService.getSubmissions({
+ formId: input.formId,
+ ownerId: ctx.user.id,
+ cursor: input.cursor,
+ limit: input.limit,
+ });
+ }),
});
diff --git a/packages/trpc/server/services/index.ts b/packages/trpc/server/services/index.ts
index b1c3805..3031ed6 100644
--- a/packages/trpc/server/services/index.ts
+++ b/packages/trpc/server/services/index.ts
@@ -5,7 +5,6 @@ import FormLogicService from "@repo/services/form-logic";
import FormDraftService from "@repo/services/form-draft";
import FormSubmissionService from "@repo/services/form-submission";
import FormUploadService from "@repo/services/form-upload";
-import AnalyticsService from "@repo/services/analytics";
import FeedbackService from "@repo/services/feedback";
export const formService = new FormService();
@@ -15,5 +14,4 @@ export const formLogicService = new FormLogicService();
export const formDraftService = new FormDraftService();
export const formSubmissionService = new FormSubmissionService();
export const formUploadService = new FormUploadService();
-export const analyticsService = new AnalyticsService();
export const feedbackService = new FeedbackService();