From 55aed28a5c8ec9b71268d47598055997a827a4ba Mon Sep 17 00:00:00 2001
From: thedittya471
Date: Thu, 30 Jul 2026 19:21:37 +0530
Subject: [PATCH 1/7] feat(web): rebuild landing page with responsive
paper-studio theme
---
.npmrc | 0
apps/web/app/globals.css | 611 ++++-
apps/web/app/layout.tsx | 14 +-
apps/web/app/page.tsx | 907 ++++++-
apps/web/components/BlendIcon.tsx | 159 ++
apps/web/components/ChartBarIcon.tsx | 127 +
apps/web/components/CheckIcon.tsx | 122 +
apps/web/components/CopyIcon.tsx | 127 +
apps/web/components/Cubes.tsx | 382 +++
apps/web/components/FeedbackWidget.tsx | 255 ++
apps/web/components/Footer.tsx | 84 +
apps/web/components/Navbar.tsx | 416 +++
apps/web/components/Noise.tsx | 87 +
apps/web/components/RocketIcon.tsx | 139 +
apps/web/components/ShareIcon.tsx | 136 +
apps/web/components/ShieldUserIcon.tsx | 144 +
apps/web/components/ZapIcon.tsx | 133 +
.../components/analytics/HourlyHeatmap.tsx | 120 -
.../components/landing/AnimatedCounter.tsx | 28 +
apps/web/components/landing/ArticleTrio.tsx | 115 -
.../components/landing/CanvasFlowMockup.tsx | 603 +++++
apps/web/components/landing/Comparison.tsx | 312 +++
apps/web/components/landing/CutDropOff.tsx | 136 -
apps/web/components/landing/FooterSunset.tsx | 58 -
apps/web/components/landing/HeroSunset.tsx | 101 -
apps/web/components/landing/HowItWorks.tsx | 218 --
.../landing/IntellegentExecution.tsx | 161 ++
.../components/landing/LandingNavSunset.tsx | 53 -
.../web/components/landing/NetworkSection.tsx | 37 -
apps/web/components/landing/ScrollReveal.tsx | 45 +
.../components/landing/StepIntoCanvasFlow.tsx | 44 -
apps/web/components/landing/TrackInApp.tsx | 50 -
.../components/landing/TypeWritterText.tsx | 49 +
apps/web/components/ui/accordion.tsx | 64 -
apps/web/components/ui/alert-dialog.tsx | 135 -
apps/web/components/ui/alert.tsx | 60 -
apps/web/components/ui/aspect-ratio.tsx | 9 -
apps/web/components/ui/avatar.tsx | 41 -
apps/web/components/ui/badge.tsx | 39 -
apps/web/components/ui/breadcrumb.tsx | 102 -
apps/web/components/ui/button-group.tsx | 78 -
apps/web/components/ui/calendar.tsx | 180 --
apps/web/components/ui/card.tsx | 75 -
apps/web/components/ui/carousel.tsx | 230 --
apps/web/components/ui/chart.tsx | 326 ---
apps/web/components/ui/checkbox.tsx | 29 -
apps/web/components/ui/collapsible.tsx | 21 -
apps/web/components/ui/command.tsx | 161 --
apps/web/components/ui/context-menu.tsx | 224 --
apps/web/components/ui/dialog.tsx | 129 -
apps/web/components/ui/drawer.tsx | 124 -
apps/web/components/ui/dropdown-menu.tsx | 228 --
apps/web/components/ui/empty.tsx | 94 -
apps/web/components/ui/field.tsx | 234 --
apps/web/components/ui/form.tsx | 152 --
apps/web/components/ui/hover-card.tsx | 38 -
apps/web/components/ui/input-group.tsx | 158 --
apps/web/components/ui/input-otp.tsx | 70 -
apps/web/components/ui/item.tsx | 172 --
apps/web/components/ui/kbd.tsx | 28 -
apps/web/components/ui/label.tsx | 21 -
apps/web/components/ui/menubar.tsx | 252 --
apps/web/components/ui/navigation-menu.tsx | 161 --
apps/web/components/ui/pagination.tsx | 106 -
apps/web/components/ui/popover.tsx | 42 -
apps/web/components/ui/progress.tsx | 28 -
apps/web/components/ui/radio-group.tsx | 45 -
apps/web/components/ui/resizable.tsx | 51 -
apps/web/components/ui/scroll-area.tsx | 56 -
apps/web/components/ui/select.tsx | 175 --
apps/web/components/ui/separator.tsx | 28 -
apps/web/components/ui/sheet.tsx | 130 -
apps/web/components/ui/sidebar.tsx | 694 -----
apps/web/components/ui/skeleton.tsx | 13 -
apps/web/components/ui/slider.tsx | 58 -
apps/web/components/ui/spinner.tsx | 16 -
apps/web/components/ui/switch.tsx | 28 -
apps/web/components/ui/table.tsx | 92 -
apps/web/components/ui/tabs.tsx | 54 -
apps/web/components/ui/toggle-group.tsx | 82 -
apps/web/components/ui/toggle.tsx | 46 -
apps/web/components/ui/tooltip.tsx | 57 -
apps/web/hooks/use-mobile.ts | 19 -
apps/web/hooks/use-toasts.ts | 197 ++
apps/web/hooks/useAnimatedCounter.ts | 23 +
apps/web/hooks/useScrollReveal.ts | 19 +
apps/web/next.config.js | 17 +-
apps/web/package.json | 33 +-
apps/web/trpc/server.ts | 11 -
pnpm-lock.yaml | 2382 +++--------------
90 files changed, 5564 insertions(+), 8546 deletions(-)
delete mode 100644 .npmrc
create mode 100644 apps/web/components/BlendIcon.tsx
create mode 100644 apps/web/components/ChartBarIcon.tsx
create mode 100644 apps/web/components/CheckIcon.tsx
create mode 100644 apps/web/components/CopyIcon.tsx
create mode 100644 apps/web/components/Cubes.tsx
create mode 100644 apps/web/components/FeedbackWidget.tsx
create mode 100644 apps/web/components/Footer.tsx
create mode 100644 apps/web/components/Navbar.tsx
create mode 100644 apps/web/components/Noise.tsx
create mode 100644 apps/web/components/RocketIcon.tsx
create mode 100644 apps/web/components/ShareIcon.tsx
create mode 100644 apps/web/components/ShieldUserIcon.tsx
create mode 100644 apps/web/components/ZapIcon.tsx
delete mode 100644 apps/web/components/analytics/HourlyHeatmap.tsx
create mode 100644 apps/web/components/landing/AnimatedCounter.tsx
delete mode 100644 apps/web/components/landing/ArticleTrio.tsx
create mode 100644 apps/web/components/landing/CanvasFlowMockup.tsx
create mode 100644 apps/web/components/landing/Comparison.tsx
delete mode 100644 apps/web/components/landing/CutDropOff.tsx
delete mode 100644 apps/web/components/landing/FooterSunset.tsx
delete mode 100644 apps/web/components/landing/HeroSunset.tsx
delete mode 100644 apps/web/components/landing/HowItWorks.tsx
create mode 100644 apps/web/components/landing/IntellegentExecution.tsx
delete mode 100644 apps/web/components/landing/LandingNavSunset.tsx
delete mode 100644 apps/web/components/landing/NetworkSection.tsx
create mode 100644 apps/web/components/landing/ScrollReveal.tsx
delete mode 100644 apps/web/components/landing/StepIntoCanvasFlow.tsx
delete mode 100644 apps/web/components/landing/TrackInApp.tsx
create mode 100644 apps/web/components/landing/TypeWritterText.tsx
delete mode 100644 apps/web/components/ui/accordion.tsx
delete mode 100644 apps/web/components/ui/alert-dialog.tsx
delete mode 100644 apps/web/components/ui/alert.tsx
delete mode 100644 apps/web/components/ui/aspect-ratio.tsx
delete mode 100644 apps/web/components/ui/avatar.tsx
delete mode 100644 apps/web/components/ui/badge.tsx
delete mode 100644 apps/web/components/ui/breadcrumb.tsx
delete mode 100644 apps/web/components/ui/button-group.tsx
delete mode 100644 apps/web/components/ui/calendar.tsx
delete mode 100644 apps/web/components/ui/card.tsx
delete mode 100644 apps/web/components/ui/carousel.tsx
delete mode 100644 apps/web/components/ui/chart.tsx
delete mode 100644 apps/web/components/ui/checkbox.tsx
delete mode 100644 apps/web/components/ui/collapsible.tsx
delete mode 100644 apps/web/components/ui/command.tsx
delete mode 100644 apps/web/components/ui/context-menu.tsx
delete mode 100644 apps/web/components/ui/dialog.tsx
delete mode 100644 apps/web/components/ui/drawer.tsx
delete mode 100644 apps/web/components/ui/dropdown-menu.tsx
delete mode 100644 apps/web/components/ui/empty.tsx
delete mode 100644 apps/web/components/ui/field.tsx
delete mode 100644 apps/web/components/ui/form.tsx
delete mode 100644 apps/web/components/ui/hover-card.tsx
delete mode 100644 apps/web/components/ui/input-group.tsx
delete mode 100644 apps/web/components/ui/input-otp.tsx
delete mode 100644 apps/web/components/ui/item.tsx
delete mode 100644 apps/web/components/ui/kbd.tsx
delete mode 100644 apps/web/components/ui/label.tsx
delete mode 100644 apps/web/components/ui/menubar.tsx
delete mode 100644 apps/web/components/ui/navigation-menu.tsx
delete mode 100644 apps/web/components/ui/pagination.tsx
delete mode 100644 apps/web/components/ui/popover.tsx
delete mode 100644 apps/web/components/ui/progress.tsx
delete mode 100644 apps/web/components/ui/radio-group.tsx
delete mode 100644 apps/web/components/ui/resizable.tsx
delete mode 100644 apps/web/components/ui/scroll-area.tsx
delete mode 100644 apps/web/components/ui/select.tsx
delete mode 100644 apps/web/components/ui/separator.tsx
delete mode 100644 apps/web/components/ui/sheet.tsx
delete mode 100644 apps/web/components/ui/sidebar.tsx
delete mode 100644 apps/web/components/ui/skeleton.tsx
delete mode 100644 apps/web/components/ui/slider.tsx
delete mode 100644 apps/web/components/ui/spinner.tsx
delete mode 100644 apps/web/components/ui/switch.tsx
delete mode 100644 apps/web/components/ui/table.tsx
delete mode 100644 apps/web/components/ui/tabs.tsx
delete mode 100644 apps/web/components/ui/toggle-group.tsx
delete mode 100644 apps/web/components/ui/toggle.tsx
delete mode 100644 apps/web/components/ui/tooltip.tsx
delete mode 100644 apps/web/hooks/use-mobile.ts
create mode 100644 apps/web/hooks/use-toasts.ts
create mode 100644 apps/web/hooks/useAnimatedCounter.ts
create mode 100644 apps/web/hooks/useScrollReveal.ts
delete mode 100644 apps/web/trpc/server.ts
diff --git a/.npmrc b/.npmrc
deleted file mode 100644
index e69de29..0000000
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 9f680c7..9670220 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -27,19 +27,6 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
}
:root {
@@ -62,23 +49,8 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
- --chart-1: oklch(0.646 0.222 41.116);
- --chart-2: oklch(0.6 0.118 184.704);
- --chart-3: oklch(0.398 0.07 227.392);
- --chart-4: oklch(0.828 0.189 84.429);
- --chart-5: oklch(0.769 0.188 70.08);
- --sidebar: oklch(0.985 0 0);
- --sidebar-foreground: oklch(0.145 0 0);
- --sidebar-primary: oklch(0.205 0 0);
- --sidebar-primary-foreground: oklch(0.985 0 0);
- --sidebar-accent: oklch(0.97 0 0);
- --sidebar-accent-foreground: oklch(0.205 0 0);
- --sidebar-border: oklch(0.922 0 0);
- --sidebar-ring: oklch(0.708 0 0);
}
-
-
@layer base {
* {
@apply border-border outline-ring/50;
@@ -111,9 +83,6 @@
--cf-line-strong: rgba(22, 19, 17, 0.14);
--cf-orange: #f66f00;
--cf-orange-hover: #e26300;
-
- /* hero dark warm photo-feel */
- --cf-hero-bg: #2a2620;
}
/* Warm text selection */
@@ -145,66 +114,554 @@
font-weight: 500;
}
-/* Full-page subtle blueprint grid (4 vertical guides + horizontal rules per section). */
-.cf-page-grid {
+/* ============================================================
+ CanvasFlow — "paper studio" landing system
+ ------------------------------------------------------------
+ Design tokens + component classes consumed by app/page.tsx and the
+ landing mockups in components/landing/. Those files reference
+ `hex-*` class names and `--hex-*` / `--c-*` custom properties, so
+ they are all declared here.
+ ============================================================ */
+
+:root {
+ /* Ink ramp */
+ --hex-ink: #1a1d29;
+ --hex-ink-soft: #5b6070;
+ --hex-ink-muted: #8f95a3;
+
+ /* Two rules, two jobs.
+
+ `--hex-line-strong` is the structural edge — the outline of a button,
+ a field, a card, a crop mark. It is near-black so those shapes read
+ as drawn boxes rather than as soft panels.
+
+ `--hex-line` is the internal hairline: row dividers, sub-panel
+ splits, the fine grid inside the mockups. It stays light on purpose.
+ Promoting it to black as well would turn every mockup into a dense
+ black lattice and bury the type inside them. */
+ --hex-line: rgba(26, 29, 41, 0.16);
+ --hex-line-strong: #1a1d29;
+
+ /* Radius for the landing chrome. Square by default — see the
+ `.hex-theme` radius reset further down. */
+ --hex-radius: 0px;
+
+ /* Paper surfaces. `--hex-paper` is the pale warm page stock and
+ `--hex-nav` the translucent band that floats on top of it.
+
+ The stock is light on purpose. Each of the page's three backgrounds
+ is an image layer painted *normally* over it, so the sheet needs
+ headroom for them to read — see .hex-hero-paper / .hex-section-paper.
+ `--hex-surface` is the near-white fill for insets inside cards.
+
+ The nav sits a shade *below* the paper rather than above it: a band
+ lighter than the sheet reads as a bright strip across the top, so
+ this is a duller greige that settles into the page instead. Alpha is
+ higher than the old near-white value to keep text contrast while the
+ tone comes down. */
+ --hex-paper: #f5f3ee;
+ --hex-nav: rgba(231, 229, 221, 0.82);
+ --hex-surface: #fdfdfb;
+
+ /* Bone: a warm off-white for outlined buttons. Kept a touch yellower
+ and lighter than the paper stock so a button reads as a panel laid on
+ the sheet, and clearly lighter than the dull nav band it sits in.
+ Pure white would be a cold hole in a page that has no pure white
+ anywhere else in its chrome. */
+ --hex-bone: #faf8f1;
+
+ /* Data-viz palette (multi-line chart, scatter, stacked bars) */
+ --c-orange: #e0834a;
+ --c-yellow: #e3b23c;
+ --c-teal: #3aa793;
+ --c-blue: #4a6bc4;
+ --c-purple: #6c5ce7;
+ --c-lavender: #a79ae4;
+ --c-red: #dd6459;
+}
+
+@layer components {
+ /* ---- Surfaces -------------------------------------------------- */
+ .hex-card {
+ background: #fff;
+ border: 1px solid var(--hex-line-strong);
+ border-radius: var(--hex-radius);
+ /* A hard offset rather than a blur. Once the card has a black outline
+ a diffuse shadow reads as a smudge behind a drawn edge; an opaque
+ step keeps the whole shape crisp and sits better on the textured
+ paper. */
+ box-shadow: 5px 5px 0 0 rgba(26, 29, 41, 0.08);
+ }
+
+ /* Border-colour helpers. These are paired with Tailwind width
+ utilities (`border`, `border-b`, `border-r`, ...) in the mockups. */
+ .hex-line-soft {
+ border-color: var(--hex-line);
+ }
+ .hex-line-strong {
+ border-color: var(--hex-line-strong);
+ }
+
+ .hex-divider {
+ height: 1px;
+ width: 100%;
+ background: var(--hex-line);
+ }
+
+ /* ---- Type ------------------------------------------------------ */
+ .hex-mono {
+ font-family: var(--font-mono-cf), ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-feature-settings: "tnum" 1;
+ }
+
+ /* ---- Dashboard chrome ------------------------------------------ */
+ .hex-kpi {
+ border: 1px solid var(--hex-line-strong);
+ border-radius: var(--hex-radius);
+ padding: 12px 14px;
+ background: var(--hex-surface);
+ }
+
+ .hex-select-label {
+ font-size: 9px;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--hex-ink-muted);
+ margin-bottom: 4px;
+ }
+ .hex-select {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ height: 30px;
+ padding: 0 10px;
+ font-size: 11px;
+ background: #fff;
+ border: 1px solid var(--hex-line-strong);
+ border-radius: var(--hex-radius);
+ }
+
+ .hex-row-hover {
+ transition: background 0.15s ease;
+ }
+ .hex-row-hover:hover {
+ background: rgba(26, 29, 41, 0.02);
+ }
+
+ /* Fine blueprint grid used behind the builder canvas.
+ The background-size is essential: without it each gradient stretches
+ to fill the element and paints a single line down its left edge and
+ across its top instead of a repeating grid. */
+ .hex-grid-fine {
+ background-image:
+ linear-gradient(to right, var(--hex-ink) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--hex-ink) 1px, transparent 1px);
+ background-size: 24px 24px;
+ }
+
+ /* Slim scrollbars inside the mockups so they read as real UI. */
+ .custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(26, 29, 41, 0.16) transparent;
+ }
+ .custom-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+ }
+ .custom-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb {
+ background: rgba(26, 29, 41, 0.16);
+ border-radius: 999px;
+ }
+}
+
+/* ---- Mockup motion ---------------------------------------------- */
+
+/* Line-chart stroke draw-on. `--len` is supplied inline per path. */
+@keyframes hex-draw {
+ from {
+ stroke-dashoffset: var(--len, 600);
+ }
+ to {
+ stroke-dashoffset: 0;
+ }
+}
+.hex-line-path {
+ stroke-dasharray: var(--len, 600);
+ animation: hex-draw 1.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+}
+
+/* Scatter dots pop in, staggered by inline animation-delay. */
+@keyframes hex-pop {
+ from {
+ transform: scale(0);
+ opacity: 0;
+ }
+ to {
+ transform: scale(1);
+ opacity: 0.78;
+ }
+}
+.hex-dot {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation: hex-pop 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+/* Stacked bars grow from the left. */
+@keyframes hex-grow-x {
+ from {
+ transform: scaleX(0);
+ }
+ to {
+ transform: scaleX(1);
+ }
+}
+.hex-bar-x {
+ transform-origin: left center;
+ animation: hex-grow-x 0.6s cubic-bezier(0.22, 1, 0.36, 1) backwards;
+}
+
+/* Dashed connector flow in the routing graph (used via
+ `animate-[dash_20s_linear_infinite]`). */
+@keyframes dash {
+ to {
+ stroke-dashoffset: -200;
+ }
+}
+
+/* CRT scanline sweep for the dark comparison matrix. */
+@keyframes scanlines {
+ from {
+ background-position: 0 0;
+ }
+ to {
+ background-position: 0 100%;
+ }
+}
+.animate-scanlines {
+ animation: scanlines 8s linear infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hex-line-path,
+ .hex-dot,
+ .hex-bar-x,
+ .animate-scanlines,
+ /* Tailwind's own looping utilities aren't covered by its reset, so the
+ live-feed indicators get listed here explicitly. */
+ .animate-ping,
+ .animate-pulse {
+ animation: none !important;
+ }
+ .hex-line-path {
+ stroke-dasharray: none;
+ }
+}
+
+/* Small mono figure caption ("FIG.01"). */
+.hex-fig {
+ font-family: var(--font-mono-cf), ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 9px;
+ letter-spacing: 0.32em;
+ text-transform: uppercase;
+ color: var(--hex-ink-muted);
+}
+
+/* ============================================================
+ Landing page chrome referenced directly by app/page.tsx
+ (hex-theme / hex-paper / hex-hero-paper / hex-section-paper /
+ hex-corner / hex-vignette, the button + link recipes, and the FAQ
+ disclosure rows).
+ ============================================================ */
+
+:root {
+ /* Ruler/scale strips: border colour and the diagonal hatch stroke. */
+ --pattern: rgba(26, 29, 41, 0.18);
+ --pattern-hatch: #d4d4d4;
+}
+
+.hex-theme {
+ color: var(--hex-ink);
+ font-family: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
+ -webkit-font-smoothing: antialiased;
+}
+
+/* ---- Boxy landing chrome -----------------------------------------
+ Squares off the corner-radius utilities inside the landing theme in
+ one place, instead of rewriting ~47 `rounded-*` call sites across
+ page.tsx, Navbar.tsx and the mockups — and, more importantly, so the
+ rule can't drift out of step as those files change.
+
+ `rounded-full` is deliberately absent from this list. Avatars, status
+ dots, radio marks and the toggle pills inside the mockups are meant to
+ be circular; flattening those would read as a bug, not a style.
+
+ The selectors are scoped under `.hex-theme` rather than global so the
+ dashboard, auth and form-filling screens keep their own rounding. Two
+ class selectors also outrank Tailwind's single-class utility, which is
+ what lets this win without `!important`. */
+.hex-theme .rounded,
+.hex-theme .rounded-sm,
+.hex-theme .rounded-md,
+.hex-theme .rounded-lg,
+.hex-theme .rounded-xl,
+.hex-theme .rounded-2xl,
+.hex-theme .rounded-3xl,
+.hex-theme .rounded-4xl,
+.hex-theme .rounded-\[1px\],
+.hex-theme .rounded-\[4px\] {
+ border-radius: var(--hex-radius);
+}
+
+/* ---- The page stock ----------------------------------------------
+ A pale warm sheet with a fine tooth. The tooth is two dot grids at
+ 3px and 7px, offset from one another so the beat between them reads
+ as paper fibre rather than as a visible dot pattern.
+
+ Nothing here is sized to the document. This element is the page root,
+ so its height is the whole scroll length — any layer measured in `%`
+ vertically would stretch across every section at once and lay a
+ top-to-bottom tonal ramp over the page, tinting the hero one shade and
+ the footer another. The page's three backgrounds are section-scoped
+ layers instead (.hex-hero-paper, .hex-section-paper), which is what
+ keeps them independently tunable. */
+.hex-paper {
+ background-color: var(--hex-paper);
+ background-image:
+ radial-gradient(rgba(26, 29, 41, 0.025) 1px, transparent 1px),
+ radial-gradient(rgba(26, 29, 41, 0.018) 1px, transparent 1px);
+ background-size:
+ 3px 3px,
+ 7px 7px;
+ background-position:
+ 0 0,
+ 1px 2px;
+}
+
+/* ---- The three section backgrounds ------------------------------
+ The landing page runs three distinct sheets, each scoped to the
+ sections that use it rather than painted page-wide:
+
+ 1 · Hero — .hex-hero-paper, a tiled crack network
+ 2 · Mid page — nothing; the bare stock above
+ 3 · Analytics — .hex-section-paper, one large plate
+
+ Both image layers paint at normal blend, *not* multiply. That is the
+ whole mechanism: the source art has a white ground, so painting it
+ normally lays down its dark structure and lifts the cream a step
+ toward white at the same time. The hero ends up the lightest of the
+ three and the bare mid-page sections the deepest, which is the tonal
+ rhythm the design is after. Multiplying would drop the white ground
+ out — white is the identity for multiply — and every section would
+ collapse back to the same tone. */
+
+/* 1 · Hero. Tiled small enough that the cells read at page scale, and
+ capped in vw so the tile doesn't dominate a narrow viewport. */
+.hex-hero-paper {
position: absolute;
inset: 0;
+ z-index: 0;
pointer-events: none;
- z-index: 1;
- background-image:
- linear-gradient(to right, var(--cf-line) 1px, transparent 1px);
- background-size: 25% 100%;
- background-position: 25% 0;
+ opacity: 0.3;
+ background-image: url("https://ik.imagekit.io/yatharth/ChatGPT%20Image%20Apr%2030,%202026,%2007_52_59%20PM.png");
+ background-size: min(420px, 45vw) auto;
+ background-repeat: repeat;
}
-/* Subtle grain texture (paper feel) */
-.cf-grain {
- background-image: radial-gradient(rgba(22, 19, 17, 0.05) 1px, transparent 1px);
- background-size: 4px 4px;
+/* 3 · Analytics. A single plate scaled to cover, so the section reads
+ as one continuous sheet rather than a repeat. Held much fainter than
+ the hero tile because it carries far more structure. */
+.hex-section-paper {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ opacity: 0.1;
+ background-image: url("https://ik.imagekit.io/yatharth/BG-SECTION-.png");
+ background-size: cover;
+ background-position: center;
+ background-repeat: no-repeat;
}
-/* Photographic-feeling dark warm hero — looks like a backlit golden-hour scene,
- no external image needed. Three layered radial+linear gradients + grain. */
-.cf-hero-photo {
- background-color: #1c1814;
- background-image:
- radial-gradient(60% 80% at 80% 90%, rgba(255, 138, 52, 0.55), transparent 65%),
- radial-gradient(50% 60% at 15% 8%, rgba(28, 22, 18, 0.85), transparent 70%),
- radial-gradient(35% 40% at 70% 25%, rgba(168, 110, 62, 0.45), transparent 70%),
- linear-gradient(180deg, #2a2620 0%, #3a2d20 45%, #6a4221 100%);
+@media (prefers-reduced-transparency: reduce) {
+ /* Plain stock for anyone who has asked for less visual noise. */
+ .hex-paper {
+ background-image: none;
+ }
+ .hex-hero-paper,
+ .hex-section-paper {
+ display: none;
+ }
}
-/* Vivid solid orange tile (used for the right-hand "energy panel" in hero). */
-.cf-orange-tile {
- background-color: #f66f00;
- background-image:
- radial-gradient(120% 90% at 50% 42%, rgba(255, 214, 138, 0.35), transparent 60%),
- repeating-linear-gradient(
- 108deg,
- #ffa84a 0px,
- #f87c14 70px,
- #f66f00 130px,
- #d85a00 190px,
- #f66f00 250px,
- #ffa84a 320px
- );
+/* Crop-mark corner bracket. Callers zero out two sides inline to pick
+ which corner it represents. */
+.hex-corner {
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--hex-line-strong);
+ pointer-events: none;
+ z-index: 2;
}
-@media (prefers-reduced-motion: reduce) {
- .cf-float,
- .cf-spin-slow {
- animation: none !important;
+/* Worn-paper vignette. Two radials that shade only the top and bottom
+ edges of a section, so a long band of stock doesn't read as flat.
+ Deliberately darkening rather than tinting with the paper colour: a
+ paper-coloured wash would cover the tooth and leave a visible dead
+ ring at the section edges. */
+.hex-vignette::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background:
+ radial-gradient(120% 60% at 50% 0%, transparent 60%, rgba(26, 29, 41, 0.04) 100%),
+ radial-gradient(120% 60% at 50% 100%, transparent 60%, rgba(26, 29, 41, 0.04) 100%);
+}
+
+@layer components {
+ /* ---- Buttons & links ------------------------------------------- */
+ .hex-btn-primary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border-radius: var(--hex-radius);
+ background: var(--hex-ink);
+ color: #fff;
+ font-weight: 500;
+ border: 1px solid var(--hex-ink);
+ transition:
+ opacity 0.2s ease,
+ transform 0.2s ease;
+ cursor: pointer;
+ }
+ .hex-btn-primary:hover {
+ opacity: 0.9;
+ }
+ .hex-btn-primary:active {
+ transform: translateY(1px);
+ }
+
+ .hex-btn-ghost {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 10px 18px;
+ border-radius: var(--hex-radius);
+ background: var(--hex-bone);
+ color: var(--hex-ink);
+ font-size: 14px;
+ /* 600, not 500. On a transparent ground the animated grain sits behind
+ the glyphs as well as around them, and a medium weight loses
+ definition against it. */
+ font-weight: 600;
+ /* 1.5px reads as a drawn edge and roughly matches the stem weight of
+ the label beside it. A hairline looks incidental at this scale, and
+ a full 2px starts to feel like a frame rather than an outline. */
+ border: 1.5px solid var(--hex-line-strong);
+ transition:
+ background 0.2s ease,
+ color 0.2s ease;
+ cursor: pointer;
+ }
+ .hex-btn-ghost:hover {
+ background: var(--hex-ink);
+ color: #fff;
+ }
+
+ .hex-link {
+ color: var(--hex-ink);
+ font-weight: 500;
+ text-decoration: underline;
+ text-decoration-color: var(--hex-line-strong);
+ text-underline-offset: 5px;
+ transition: text-decoration-color 0.2s ease;
+ }
+ .hex-link:hover {
+ text-decoration-color: var(--hex-ink);
}
}
-@keyframes cf-float {
- 0%, 100% { transform: translateY(0px); }
- 50% { transform: translateY(-10px); }
+/* ---- FAQ disclosure rows ----------------------------------------- */
+.hex-faq-row > summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ /* Tighter on phones. A 24px gap plus 22px of vertical padding costs a
+ lot of width and height when six of these stack on a 375px screen. */
+ gap: 14px;
+ padding: 16px 2px;
+ cursor: pointer;
+ list-style: none;
}
-.cf-float { animation: cf-float 7s ease-in-out infinite; }
-@keyframes cf-spin-slow {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
+@media (min-width: 640px) {
+ .hex-faq-row > summary {
+ gap: 24px;
+ padding: 22px 4px;
+ }
+}
+/* Hide the native disclosure marker in both engines. */
+.hex-faq-row > summary::-webkit-details-marker {
+ display: none;
+}
+.hex-faq-row > summary::marker {
+ content: "";
+}
+.hex-faq-row > summary:focus-visible {
+ outline: 2px solid var(--hex-ink);
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+.hex-faq-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ flex: none;
+ border-radius: var(--hex-radius);
+ border: 1px solid var(--hex-line-strong);
+ font-size: 13px;
+ line-height: 1;
+ color: var(--hex-ink-soft);
+ transition:
+ transform 0.25s ease,
+ background 0.2s ease,
+ color 0.2s ease;
+}
+.hex-faq-row[open] .hex-faq-icon {
+ transform: rotate(45deg);
+ background: var(--hex-ink);
+ color: #fff;
+ border-color: var(--hex-ink);
+}
+
+.hex-faq-body {
+ padding: 0 2px 18px;
+ max-width: 62ch;
+ font-size: 14px;
+ line-height: 1.65;
+ color: var(--hex-ink-soft);
+}
+
+@media (min-width: 640px) {
+ .hex-faq-body {
+ padding: 0 4px 24px;
+ font-size: 15px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hex-faq-icon {
+ transition: none;
+ }
}
-.cf-spin-slow { animation: cf-spin-slow 60s linear infinite; }
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 231366c..ce8d6c4 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
-import { Fraunces, Inter, JetBrains_Mono } from "next/font/google";
+import { Fraunces, Instrument_Serif, Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import { GlobalProviders } from "~/providers/global";
@@ -34,6 +34,16 @@ const jbMono = JetBrains_Mono({
display: "swap",
});
+// Editorial italic accent inside the landing headlines
+// ("…for when you want to click around.", "No ceremony.").
+const instrumentSerif = Instrument_Serif({
+ subsets: ["latin"],
+ weight: "400",
+ style: ["normal", "italic"],
+ variable: "--font-instrument-serif",
+ display: "swap",
+});
+
export const metadata: Metadata = {
title: "CanvasFlow",
description: "Form Builder",
@@ -50,7 +60,7 @@ export default function RootLayout({
return (
{children}
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index 2f71fd6..a7b7b3d 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -1,32 +1,887 @@
"use client";
-import React from "react";
-import { authClient } from "~/lib/auth-client";
-import { LandingNavSunset } from "~/components/landing/LandingNavSunset";
-import { HeroSunset } from "~/components/landing/HeroSunset";
-import { HowItWorks } from "~/components/landing/HowItWorks";
-import { CutDropOff } from "~/components/landing/CutDropOff";
-import { TrackInApp } from "~/components/landing/TrackInApp";
-import { ArticleTrio } from "~/components/landing/ArticleTrio";
-import { NetworkSection } from "~/components/landing/NetworkSection";
-import { StepIntoCanvasFlow } from "~/components/landing/StepIntoCanvasFlow";
-import { FooterSunset } from "~/components/landing/FooterSunset";
-
-export default function LandingPage() {
- const { data: session } = authClient.useSession();
- const isLoggedIn = !!session;
+import React, { useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { motion } from "motion/react";
+import { Loader2 } from "lucide-react";
+import { toast } from "sonner";
+
+import Footer from "~/components/Footer";
+import Navbar from "~/components/Navbar";
+import Noise from "~/components/Noise";
+import {
+ GeoGlyph,
+ FormBuilderMock,
+ ResponseFeedMock,
+ CanvasEditorMock,
+ AnalyticsMock,
+} from "~/components/landing/CanvasFlowMockup";
+import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
+import { useCreateForm } from "~/hooks/api/form";
+import { cn } from "~/lib/utils";
+
+/* ── Ruler strips ──────────────────────────────────────────────────── */
+
+const HATCH =
+ "bg-[repeating-linear-gradient(315deg,_var(--pattern-hatch)_0px,_var(--pattern-hatch)_1px,_transparent_1px,_transparent_10px)] bg-[length:14px_14px]";
+
+const HorizontalScale = ({ className }: { className?: string }) => (
+
+);
+
+const VerticalScale = ({ className }: { className?: string }) => (
+
+);
+
+/* ── Copy ──────────────────────────────────────────────────────────── */
+
+const STARTERS = ["Customer feedback", "Event registration", "Job application"];
+
+const FIELD_TYPES = [
+ "Short text",
+ "Long text",
+ "Email",
+ "Phone",
+ "URL",
+ "Number",
+ "Dropdown",
+ "Checkboxes",
+ "Rating",
+ "Toggle",
+ "Date",
+ "Time",
+];
+
+const FAQS = [
+ {
+ q: "How is CanvasFlow different from Google Forms?",
+ a: "Respondents answer one question at a time with a progress bar, so long forms don't feel long. On your side you get views as well as responses, completion rate, drop-off per question, and device split — not just a spreadsheet of answers.",
+ },
+ {
+ q: "Do my respondents need an account?",
+ a: "No. Once you publish a form, anyone with the link can fill it in. CanvasFlow keeps one response per visitor and ignores duplicate submits, so a refresh or a double-click won't skew your numbers.",
+ },
+ {
+ q: "Which field types can I use?",
+ a: `${FIELD_TYPES.slice(0, -1).join(", ")}, and ${FIELD_TYPES.at(-1)?.toLowerCase()}. Each field can be marked required, given a placeholder and a description, and dragged into any order. Email and URL fields are format-checked as people type.`,
+ },
+ {
+ q: "How do I stop collecting responses?",
+ a: "Close the form with one toggle, set an expiry date, or cap the total number of submissions. You can also unpublish it entirely, which puts it back into draft and takes the public link offline.",
+ },
+ {
+ q: "Can I export my responses?",
+ a: "Yes. Every submission lands in a paginated table you can read through, and you can download the whole set as CSV whenever you like.",
+ },
+ {
+ q: "Can my team help build a form?",
+ a: "Invite teammates as collaborators and give each one access per area — the builder, the analytics, the responses, or the settings. You can change someone's role later, remove them, or transfer ownership outright.",
+ },
+];
+
+/** Turn a form title into a URL-safe, reasonably unique slug. */
+const slugify = (value: string) => {
+ const base = value
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 60);
+ const suffix = Math.random().toString(36).slice(2, 8);
+ return `${base || "form"}-${suffix}`;
+};
+
+/* ── Page ──────────────────────────────────────────────────────────── */
+
+const Index = () => {
+ const [title, setTitle] = useState("");
+ const { userInfo: user } = useGetLoggedInUserInfo();
+ const { createFormAsync, isPending } = useCreateForm();
+ const router = useRouter();
+
+ const handleCreate = async () => {
+ if (!user) {
+ router.push("/signUp");
+ return;
+ }
+ if (!title.trim() || isPending) return;
+
+ const formTitle = title.trim().slice(0, 150);
+ const toastId = toast.loading("Creating your form...", {
+ description: "Opening a fresh canvas.",
+ });
+
+ try {
+ const created = await createFormAsync({ title: formTitle, slug: slugify(formTitle) });
+
+ toast.success("Form created", {
+ id: toastId,
+ description: `Opening "${formTitle}" in the builder.`,
+ });
+
+ router.push(`/dashboard/sketches/${created.id}`);
+ } catch (error) {
+ console.error(error);
+ toast.error("Could not create the form", {
+ id: toastId,
+ description:
+ error instanceof Error ? error.message : "Something went wrong. Please try again.",
+ });
+ }
+ };
return (
-
-
-
-
-
-
-
-
-
-
+
+ {/* Animated film grain over the whole viewport. Refreshed every 4th
+ frame rather than every 2nd to cut the per-frame pixel work in
+ half without the grain looking static. */}
+
+
+ {/* Ruled page margins. These are overlays, not layout, so on narrow
+ screens they would sit on top of the copy rather than beside it —
+ the gutter that makes room for them only exists from md up. */}
+
+
+
+
+
+
+
+ {/* ── Hero ───────────────────────────────────────────────────── */}
+ {/* The bottom rule is the seam where background 1 hands off to
+ background 2. Every change of sheet on the page gets one, so the
+ switch reads as a deliberate edge rather than a gradient. */}
+
+ {/* Background 1 of 3: tiled crack network, the lightest sheet. */}
+
+ {/* Crop marks pull in on small screens so they don't collide with
+ the body text, and are hidden outright below sm where there is
+ no spare gutter for them to sit in. */}
+
+
+
+
+
+
+
+ {/* Four steps rather than two. The headline is the widest
+ thing on the page, and 64px only fits from roughly
+ 640px up — below that it overflows a 375px screen. */}
+
+ Forms,
+
+ thoughtfully
+
+
+
+
+
+ built for teams.
+
+
+
+ Twelve field types, one question at a time for whoever fills it in, and real
+ numbers on the other side. Publish with a link, close it when you’re done.
+
+
+
+ {/* Field and button share one drawn box: the wrapper
+ carries the black outline and the submit sits flush
+ inside it, so the pair reads as a single control. */}
+
+
+ Name your form
+
+ setTitle(e.target.value)}
+ placeholder="Name your form..."
+ className="w-full border-none bg-transparent px-4 py-3 text-[15px] placeholder:text-muted-foreground/60 focus:outline-none sm:flex-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ void handleCreate();
+ }
+ }}
+ />
+ void handleCreate()}
+ disabled={isPending}
+ className="flex w-full items-center justify-center gap-2 rounded-none bg-foreground px-6 py-2.5 text-[13px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-50 sm:w-auto"
+ >
+ {isPending ? : "Create form"}
+
+
+
+
+
+
+ Start with:
+
+ {STARTERS.map((s) => (
+ setTitle(s)}
+ className="rounded-none border hex-line-strong bg-transparent px-3 py-1.5 text-[12px] font-medium text-foreground/80 transition-colors hover:bg-foreground hover:text-background"
+ >
+ {s}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* The wide mock takes the larger column and the upward
+ offset, so the pair reads as staggered rather than as
+ two cards on a shared baseline. */}
+
+
+
+
+
+
+
+
+ {/* ── FIG.01 · The canvas ────────────────────────────────────── */}
+
+ A canvas that
+ stays out of the way.
+ >
+ }
+ body="Sane defaults. No fifty-tab settings panels. Drop in a field, write the question, mark it required — your form is already publishable, accessible, and fast on mobile."
+ cta="Open the builder"
+ mock={ }
+ reverse
+ />
+
+ {/* ── FIG.02 · Dashboards ────────────────────────────────────── */}
+
+ {/* Background 3 of 3: one large plate scaled to cover, plus the
+ worn-paper vignette from `hex-vignette` on the section. */}
+
+
+
+
+
+
+
+ FIG.02
+
+
+ Beautiful dashboards,{" "}
+
+ for when you want to click around.
+
+
+
+ Views and responses, completion rate, the hour and the day people actually reply,
+ and which question they gave up on. Your form becomes a real dashboard the second
+ answers land — no exports, no spreadsheets.
+
+
+ Explore analytics →
+
+
+
+
+
+
+
+
+ {/* ── FIG.03 · Sharing & access ──────────────────────────────── */}
+
+ {/* Background 2 of 3: the bare page stock, no overlay. */}
+
+
+
+
+
+ FIG.03
+
+
+ One link.
+
+ You decide when it closes.
+
+
+
+ Publish and share the link, or hand over a QR code. Close the form with a toggle,
+ give it an expiry date, or cap the number of submissions. Every response is kept to
+ one per visitor.
+
+
+ Your forms →
+
+
+
+
+
+
+
+
+
+ {/* ── Three steps ────────────────────────────────────────────── */}
+
+ {/* Background 2 of 3: the bare page stock, no overlay. */}
+
+
+
+ Three steps.{" "}
+
+ No ceremony.
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+ {/* ── Collaboration ──────────────────────────────────────────── */}
+
+
+
+
+
+
+ FIG.04
+
+
+ Bring the team,{" "}
+
+ not the whole company.
+
+
+
+
+ {[
+ {
+ h: "Access per area",
+ d: "Give a collaborator the builder, the analytics, the responses, or the settings — separately.",
+ },
+ {
+ h: "Roles you can change",
+ d: "Promote, demote, or remove someone at any time without rebuilding the form.",
+ },
+ {
+ h: "Hand over ownership",
+ d: "Transfer a form to someone else outright when it stops being yours to run.",
+ },
+ {
+ h: "Sign in your way",
+ d: "Email and password, or Google and GitHub if you'd rather skip another password.",
+ },
+ ].map((item) => (
+
+
{item.h}
+
+ {item.d}
+
+
+ ))}
+
+
+
+
+
+ {/* ── FAQ ────────────────────────────────────────────────────── */}
+
+
+
+
+
+ Questions,{" "}
+
+ answered.
+
+
+
+ The short version of what teams ask us most. Want the long one?
+
+
+ Talk to the team →
+
+
+
+ {FAQS.map((f, i) => (
+
+
+
+
+ 0{i + 1}
+
+ {f.q}
+
+ +
+
+
+ {f.a}
+
+
+ ))}
+
+
+
+
+
);
-}
+};
+
+/* ── Sharing & access mock ─────────────────────────────────────────── */
+
+const ShareAccessMock = () => (
+
+
+ Share · Customer Feedback
+
+ published
+
+
+
+
+ {/* Public link */}
+
+
Public link
+
+
+ canvasflow.app/forms/customer-feedback
+
+
+ Copy
+
+
+
+
+ {/* QR + access controls. The QR column is a fixed 104px, which leaves
+ too little for the toggle rows beside it on a phone — below sm the
+ two stack and the QR is capped so it doesn't blow up full-width. */}
+
+
+
QR code
+
+ {/* Deterministic pattern so server and client markup match. */}
+ {Array.from({ length: 49 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
Access
+
+ {[
+ { l: "Accepting responses", v: "On", on: true },
+ { l: "Expires", v: "31 Aug 2026", on: true },
+ { l: "Max submissions", v: "500", on: true },
+ { l: "One response per visitor", v: "On", on: true },
+ ].map((row) => (
+
+
+ {row.l}
+
+
+ {row.v}
+
+
+
+
+
+ ))}
+
+
+
+
+
+ 328 / 500 submissions
+ closes in 32 days
+
+
+
+);
+
+/* ── Step cards ────────────────────────────────────────────────────── */
+
+const FeatureStepCard = ({
+ n,
+ t,
+ d,
+ illustration,
+}: {
+ n: string;
+ t: string;
+ d: string;
+ illustration: React.ReactNode;
+}) => (
+
+
+
+
+
+ {/* The illustrations are drawn at a fixed 224x160. Scaling the whole
+ group down on small screens keeps their internal proportions
+ intact, which reflowing their parts would not. */}
+
+ {illustration}
+
+
+
+
+);
+
+/* ── Step illustrations ────────────────────────────────────────────── */
+
+const BuildIllustration = () => (
+
+);
+
+const ShareIllustration = () => (
+
+);
+
+const SignalIllustration = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ LIVE
+
+
+ 87%
+
+
+);
+
+/* ── Alternating feature block ─────────────────────────────────────── */
+
+const FeatureBlock = ({
+ id,
+ glyph,
+ title,
+ body,
+ cta,
+ mock,
+ reverse,
+}: {
+ id?: string;
+ glyph: string;
+ title: React.ReactNode;
+ body: string;
+ cta: string;
+ mock: React.ReactNode;
+ reverse?: boolean;
+}) => {
+ const { userInfo: user } = useGetLoggedInUserInfo();
+ const router = useRouter();
+
+ return (
+
+
+
*:first-child]:order-2"
+ : "lg:grid-cols-[1fr_1.8fr]",
+ )}
+ >
+
+
+
+ FIG.{glyph}
+
+
{title}
+
+ {body}
+
+
+ router.push(user ? "/dashboard" : "/signUp")}
+ className="hex-btn-primary px-6 py-3 text-[14px] sm:px-8"
+ >
+ {cta} →
+
+
+
+
+
+
+
+
+ );
+};
+
+export default Index;
diff --git a/apps/web/components/BlendIcon.tsx b/apps/web/components/BlendIcon.tsx
new file mode 100644
index 0000000..3340bfd
--- /dev/null
+++ b/apps/web/components/BlendIcon.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface BlendHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface BlendProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const BlendIcon = forwardRef
(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
+
+ const groupVariants: Variants = {
+ normal: { rotate: 0, scale: 1 },
+ animate: {
+ rotate: [0, -1.5, 1.5, 0],
+ scale: [1, 1.02, 1],
+ transition: {
+ duration: 0.7 * duration,
+ ease: "easeInOut" as const,
+ },
+ },
+ };
+
+ const leftCircle: Variants = {
+ normal: { strokeDashoffset: 0, opacity: 1, x: 0 },
+ animate: {
+ strokeDashoffset: [48, 0],
+ opacity: [0.45, 1],
+ transition: { duration: 0.6 * duration, ease: "easeInOut" },
+ },
+ };
+
+ const rightCircle: Variants = {
+ normal: { strokeDashoffset: 0, opacity: 1, x: 0 },
+ animate: {
+ strokeDashoffset: [48, 0],
+ opacity: [0.45, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ delay: 0.09,
+ },
+ },
+ };
+
+ const overlapPulse: Variants = {
+ normal: { scale: 1, opacity: 1 },
+ animate: {
+ scale: [1, 1.06, 1],
+ opacity: [0.9, 1, 1],
+ transition: {
+ duration: 0.5 * duration,
+ ease: "easeInOut",
+ delay: 0.25,
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+BlendIcon.displayName = "BlendIcon";
+export { BlendIcon };
\ No newline at end of file
diff --git a/apps/web/components/ChartBarIcon.tsx b/apps/web/components/ChartBarIcon.tsx
new file mode 100644
index 0000000..ab6f02c
--- /dev/null
+++ b/apps/web/components/ChartBarIcon.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface ChartBarIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface ChartBarIconProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const ChartBarIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isControlled.current) {
+ controls.start("normal");
+ } else {
+ onMouseLeave?.(e as any);
+ }
+ },
+ [controls, onMouseLeave],
+ );
+
+ const pathVariants: Variants = {
+ normal: {
+ pathLength: 1,
+ opacity: 1,
+ transition: { duration: 0.2 * duration },
+ },
+ animate: {
+ pathLength: [0, 1],
+ opacity: [0.7, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
+
+ const chartVariants: Variants = {
+ normal: {
+ scale: 1,
+ transition: { duration: 0.2 * duration },
+ },
+ animate: {
+ scale: [1, 1.05, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+ChartBarIcon.displayName = "ChartBarIcon";
+export { ChartBarIcon };
\ No newline at end of file
diff --git a/apps/web/components/CheckIcon.tsx b/apps/web/components/CheckIcon.tsx
new file mode 100644
index 0000000..3ed6139
--- /dev/null
+++ b/apps/web/components/CheckIcon.tsx
@@ -0,0 +1,122 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface DoubleCheckHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface CheckCheckIconProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const CheckCheckIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
+
+ const tick1Variants: Variants = {
+ normal: { strokeDashoffset: 0, scale: 1, opacity: 1 },
+ animate: {
+ strokeDashoffset: [20, 0],
+ scale: [1, 1.2, 1],
+ opacity: [0.5, 1],
+ transition: { duration: 0.7 * duration, ease: "easeInOut" },
+ },
+ };
+
+ const tick2Variants: Variants = {
+ normal: { opacity: 1, x: 0 },
+ animate: {
+ opacity: [0, 1],
+ x: [-6, 0],
+ transition: { duration: 0.5 * duration, ease: "easeOut", delay: 0.35 },
+ },
+ };
+
+ return (
+
+
+
+
+
+
+ );
+ },
+);
+
+CheckCheckIcon.displayName = "CheckCheckIcon";
+export { CheckCheckIcon };
\ No newline at end of file
diff --git a/apps/web/components/CopyIcon.tsx b/apps/web/components/CopyIcon.tsx
new file mode 100644
index 0000000..2a71a8a
--- /dev/null
+++ b/apps/web/components/CopyIcon.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Transition } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface CopyIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface CopyIconProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const CopyIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isControlled.current) {
+ controls.start("normal");
+ } else {
+ onMouseLeave?.(e as any);
+ }
+ },
+ [controls, onMouseLeave],
+ );
+ const defaultTransition: Transition = {
+ type: "spring",
+ stiffness: 160,
+ damping: 17,
+ mass: 1,
+ };
+
+ const boxVariants = {
+ normal: { translateY: 0, translateX: 0, rotate: 0 },
+ animate: { translateY: -3, translateX: -3, rotate: 360 },
+ };
+
+ const pathVariants = {
+ normal: { x: 0, y: 0 },
+ animate: { x: 3, y: 3 },
+ };
+
+ return (
+
+
+
+
+
+
+ );
+ },
+);
+
+CopyIcon.displayName = "CopyIcon";
+
+export { CopyIcon };
\ No newline at end of file
diff --git a/apps/web/components/Cubes.tsx b/apps/web/components/Cubes.tsx
new file mode 100644
index 0000000..430c001
--- /dev/null
+++ b/apps/web/components/Cubes.tsx
@@ -0,0 +1,382 @@
+"use client";
+
+import React, { useCallback, useEffect, useRef } from 'react';
+import gsap from 'gsap';
+
+interface Gap {
+ row: number;
+ col: number;
+}
+interface Duration {
+ enter: number;
+ leave: number;
+}
+
+export interface CubesProps {
+ gridSize?: number;
+ cubeSize?: number;
+ maxAngle?: number;
+ radius?: number;
+ easing?: gsap.EaseString;
+ duration?: Duration;
+ cellGap?: number | Gap;
+ borderStyle?: string;
+ faceColor?: string;
+ shadow?: boolean | string;
+ autoAnimate?: boolean;
+ rippleOnClick?: boolean;
+ rippleColor?: string;
+ rippleSpeed?: number;
+}
+
+const Cubes: React.FC = ({
+ gridSize = 10,
+ cubeSize,
+ maxAngle = 45,
+ radius = 3,
+ easing = 'power3.out',
+ duration = { enter: 0.3, leave: 0.6 },
+ cellGap,
+ borderStyle = '1px solid #fff',
+ faceColor = '#120F17',
+ shadow = false,
+ autoAnimate = true,
+ rippleOnClick = true,
+ rippleColor = '#fff',
+ rippleSpeed = 2
+}) => {
+ const sceneRef = useRef(null);
+ const rafRef = useRef(null);
+ const idleTimerRef = useRef | null>(null);
+ const userActiveRef = useRef(false);
+ const simPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const simTargetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const simRAFRef = useRef(null);
+
+ const colGap =
+ typeof cellGap === 'number'
+ ? `${cellGap}px`
+ : (cellGap as Gap)?.col !== undefined
+ ? `${(cellGap as Gap).col}px`
+ : '5%';
+ const rowGap =
+ typeof cellGap === 'number'
+ ? `${cellGap}px`
+ : (cellGap as Gap)?.row !== undefined
+ ? `${(cellGap as Gap).row}px`
+ : '5%';
+
+ const enterDur = duration.enter;
+ const leaveDur = duration.leave;
+
+ const tiltAt = useCallback(
+ (rowCenter: number, colCenter: number) => {
+ if (!sceneRef.current) return;
+ sceneRef.current.querySelectorAll('.cube').forEach(cube => {
+ const r = +cube.dataset.row!;
+ const c = +cube.dataset.col!;
+ const dist = Math.hypot(r - rowCenter, c - colCenter);
+ if (dist <= radius) {
+ const pct = 1 - dist / radius;
+ const angle = pct * maxAngle;
+ gsap.to(cube, {
+ duration: enterDur,
+ ease: easing,
+ overwrite: true,
+ rotateX: -angle,
+ rotateY: angle
+ });
+ } else {
+ gsap.to(cube, {
+ duration: leaveDur,
+ ease: 'power3.out',
+ overwrite: true,
+ rotateX: 0,
+ rotateY: 0
+ });
+ }
+ });
+ },
+ [radius, maxAngle, enterDur, leaveDur, easing]
+ );
+
+ const onPointerMove = useCallback(
+ (e: PointerEvent) => {
+ userActiveRef.current = true;
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
+
+ const rect = sceneRef.current!.getBoundingClientRect();
+ const cellW = rect.width / gridSize;
+ const cellH = rect.height / gridSize;
+ const colCenter = (e.clientX - rect.left) / cellW;
+ const rowCenter = (e.clientY - rect.top) / cellH;
+
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
+ rafRef.current = requestAnimationFrame(() => tiltAt(rowCenter, colCenter));
+
+ idleTimerRef.current = setTimeout(() => {
+ userActiveRef.current = false;
+ }, 3000);
+ },
+ [gridSize, tiltAt]
+ );
+
+ const resetAll = useCallback(() => {
+ if (!sceneRef.current) return;
+ sceneRef.current.querySelectorAll('.cube').forEach(cube =>
+ gsap.to(cube, {
+ duration: leaveDur,
+ rotateX: 0,
+ rotateY: 0,
+ ease: 'power3.out'
+ })
+ );
+ }, [leaveDur]);
+
+ const onTouchMove = useCallback(
+ (e: TouchEvent) => {
+ e.preventDefault();
+ userActiveRef.current = true;
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
+
+ const rect = sceneRef.current!.getBoundingClientRect();
+ const cellW = rect.width / gridSize;
+ const cellH = rect.height / gridSize;
+
+ const touch = e.touches[0];
+ if (!touch) return;
+ const colCenter = (touch.clientX - rect.left) / cellW;
+ const rowCenter = (touch.clientY - rect.top) / cellH;
+
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
+ rafRef.current = requestAnimationFrame(() => tiltAt(rowCenter, colCenter));
+
+ idleTimerRef.current = setTimeout(() => {
+ userActiveRef.current = false;
+ }, 3000);
+ },
+ [gridSize, tiltAt]
+ );
+
+ const onTouchStart = useCallback(() => {
+ userActiveRef.current = true;
+ }, []);
+
+ const onTouchEnd = useCallback(() => {
+ if (!sceneRef.current) return;
+ resetAll();
+ }, [resetAll]);
+
+ const onClick = useCallback(
+ (e: MouseEvent | TouchEvent) => {
+ if (!rippleOnClick || !sceneRef.current) return;
+ const rect = sceneRef.current.getBoundingClientRect();
+ const cellW = rect.width / gridSize;
+ const cellH = rect.height / gridSize;
+
+ const touchPoint = (e as TouchEvent).touches?.[0];
+ const clientX = (e as MouseEvent).clientX || touchPoint?.clientX || 0;
+ const clientY = (e as MouseEvent).clientY || touchPoint?.clientY || 0;
+
+ const colHit = Math.floor((clientX - rect.left) / cellW);
+ const rowHit = Math.floor((clientY - rect.top) / cellH);
+
+ const baseRingDelay = 0.15;
+ const baseAnimDur = 0.3;
+ const baseHold = 0.6;
+
+ const spreadDelay = baseRingDelay / rippleSpeed;
+ const animDuration = baseAnimDur / rippleSpeed;
+ const holdTime = baseHold / rippleSpeed;
+
+ const rings: Record = {};
+ sceneRef.current.querySelectorAll('.cube').forEach(cube => {
+ const r = +cube.dataset.row!;
+ const c = +cube.dataset.col!;
+ const dist = Math.hypot(r - rowHit, c - colHit);
+ const ring = Math.round(dist);
+ if (!rings[ring]) rings[ring] = [];
+ rings[ring].push(cube);
+ });
+
+ Object.keys(rings)
+ .map(Number)
+ .sort((a, b) => a - b)
+ .forEach(ring => {
+ const delay = ring * spreadDelay;
+ const faces = (rings[ring] ?? []).flatMap(cube =>
+ Array.from(cube.querySelectorAll('.cube-face'))
+ );
+
+ gsap.to(faces, {
+ backgroundColor: rippleColor,
+ duration: animDuration,
+ delay,
+ ease: 'power3.out'
+ });
+ gsap.to(faces, {
+ backgroundColor: faceColor,
+ duration: animDuration,
+ delay: delay + animDuration + holdTime,
+ ease: 'power3.out'
+ });
+ });
+ },
+ [rippleOnClick, gridSize, faceColor, rippleColor, rippleSpeed]
+ );
+
+ useEffect(() => {
+ if (!autoAnimate || !sceneRef.current) return;
+ simPosRef.current = {
+ x: Math.random() * gridSize,
+ y: Math.random() * gridSize
+ };
+ simTargetRef.current = {
+ x: Math.random() * gridSize,
+ y: Math.random() * gridSize
+ };
+ const speed = 0.02;
+ const loop = () => {
+ if (!userActiveRef.current) {
+ const pos = simPosRef.current;
+ const tgt = simTargetRef.current;
+ pos.x += (tgt.x - pos.x) * speed;
+ pos.y += (tgt.y - pos.y) * speed;
+ tiltAt(pos.y, pos.x);
+ if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) {
+ simTargetRef.current = {
+ x: Math.random() * gridSize,
+ y: Math.random() * gridSize
+ };
+ }
+ }
+ simRAFRef.current = requestAnimationFrame(loop);
+ };
+ simRAFRef.current = requestAnimationFrame(loop);
+ return () => {
+ if (simRAFRef.current != null) cancelAnimationFrame(simRAFRef.current);
+ };
+ }, [autoAnimate, gridSize, tiltAt]);
+
+ useEffect(() => {
+ const el = sceneRef.current;
+ if (!el) return;
+ el.addEventListener('pointermove', onPointerMove);
+ el.addEventListener('pointerleave', resetAll);
+ el.addEventListener('click', onClick);
+
+ el.addEventListener('touchmove', onTouchMove, { passive: false });
+ el.addEventListener('touchstart', onTouchStart, { passive: true });
+ el.addEventListener('touchend', onTouchEnd, { passive: true });
+
+ return () => {
+ el.removeEventListener('pointermove', onPointerMove);
+ el.removeEventListener('pointerleave', resetAll);
+ el.removeEventListener('click', onClick);
+
+ el.removeEventListener('touchmove', onTouchMove);
+ el.removeEventListener('touchstart', onTouchStart);
+ el.removeEventListener('touchend', onTouchEnd);
+
+ if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
+ };
+ }, [onPointerMove, resetAll, onClick, onTouchMove, onTouchStart, onTouchEnd]);
+
+ const cells = Array.from({ length: gridSize });
+ const sceneStyle: React.CSSProperties = {
+ gridTemplateColumns: cubeSize ? `repeat(${gridSize}, ${cubeSize}px)` : `repeat(${gridSize}, 1fr)`,
+ gridTemplateRows: cubeSize ? `repeat(${gridSize}, ${cubeSize}px)` : `repeat(${gridSize}, 1fr)`,
+ columnGap: colGap,
+ rowGap: rowGap,
+ perspective: '99999999px',
+ gridAutoRows: '1fr'
+ };
+ const wrapperStyle = {
+ '--cube-face-border': borderStyle,
+ '--cube-face-bg': faceColor,
+ '--cube-face-shadow': shadow === true ? '0 0 6px rgba(0,0,0,.5)' : shadow || 'none',
+ ...(cubeSize
+ ? {
+ width: `${gridSize * cubeSize}px`,
+ height: `${gridSize * cubeSize}px`
+ }
+ : {})
+ } as React.CSSProperties;
+
+ return (
+
+
+ {cells.map((_, r) =>
+ cells.map((__, c) => (
+
+ ))
+ )}
+
+
+ );
+};
+
+export default Cubes;
\ No newline at end of file
diff --git a/apps/web/components/FeedbackWidget.tsx b/apps/web/components/FeedbackWidget.tsx
new file mode 100644
index 0000000..ad01630
--- /dev/null
+++ b/apps/web/components/FeedbackWidget.tsx
@@ -0,0 +1,255 @@
+"use client";
+
+import React, { FormEvent, useEffect, useRef, useState } from 'react';
+import { MessageSquareWarning, X, Send, Bug, MessageCircle, Lightbulb, AlertTriangle } from 'lucide-react';
+import { toast } from 'sonner';
+import { env } from '~/env.js';
+import { useGetLoggedInUserInfo } from '~/hooks/api/auth';
+import { cn } from '~/lib/utils';
+import { Button } from '~/components/ui/button';
+import { Input } from '~/components/ui/input';
+import { Textarea } from '~/components/ui/textarea';
+
+type ComplaintType = 'bug' | 'feedback' | 'complaint' | 'feature_request';
+
+const typeConfig: Record = {
+ feedback: {
+ label: 'Feedback',
+ description: 'Share product thoughts',
+ icon: ,
+ },
+ bug: {
+ label: 'Bug report',
+ description: 'Something is broken',
+ icon: ,
+ },
+ feature_request: {
+ label: 'Feature request',
+ description: 'Request an improvement',
+ icon: ,
+ },
+ complaint: {
+ label: 'Complaint',
+ description: 'Escalate an issue',
+ icon: ,
+ },
+};
+
+const FeedbackWidget = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [type, setType] = useState('feedback');
+ const [subject, setSubject] = useState('');
+ const [message, setMessage] = useState('');
+ const [loading, setLoading] = useState(false);
+ const subjectRef = useRef(null);
+ const { userInfo: user } = useGetLoggedInUserInfo();
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const focusTimer = window.setTimeout(() => subjectRef.current?.focus(), 100);
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') setIsOpen(false);
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.clearTimeout(focusTimer);
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isOpen]);
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+
+ if (!subject.trim() || !message.trim()) {
+ toast.error('Please fill in all fields');
+ return;
+ }
+
+ setLoading(true);
+ try {
+ const apiBase = (env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000').replace(/\/trpc$/, '');
+
+ const response = await fetch(`${apiBase}/api/complaints`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ userId: user?.id ?? null,
+ userEmail: user?.email ?? 'anonymous',
+ type,
+ subject: subject.trim(),
+ message: message.trim(),
+ status: 'open',
+ priority: type === 'bug' ? 'high' : 'medium',
+ }),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Request failed with status ${response.status}`);
+ }
+
+ toast.success('Submitted successfully! We\'ll get back to you.');
+ setSubject('');
+ setMessage('');
+ setType('feedback');
+ setIsOpen(false);
+ } catch (err) {
+ console.error(err);
+ toast.error('Failed to submit. Please try again.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const canSubmit = Boolean(subject.trim() && message.trim()) && !loading;
+
+ return (
+ <>
+ setIsOpen(true)}
+ variant="default"
+ size="icon"
+ className={cn(
+ "fixed bottom-5 right-5 z-50 h-12 w-12 border border-foreground/15 bg-foreground text-background shadow-brutal-sm hover:bg-foreground/90 focus-visible:ring-accent md:bottom-6 md:right-6",
+ isOpen && "hidden"
+ )}
+ title="Report Issue / Feedback"
+ aria-label="Open report and feedback form"
+ >
+
+
+
+ {isOpen && (
+
+
+
+
+
+ Send bugs, feedback, complaints, or requests.
+
+
+
setIsOpen(false)}
+ className="h-8 w-8 shrink-0"
+ aria-label="Close report and feedback form"
+ >
+
+
+
+
+
+
+ )}
+ >
+ );
+};
+
+export default FeedbackWidget;
\ No newline at end of file
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
new file mode 100644
index 0000000..6c0e368
--- /dev/null
+++ b/apps/web/components/Footer.tsx
@@ -0,0 +1,84 @@
+"use client";
+
+import React from 'react';
+import Link from 'next/link';
+
+const Footer = () => {
+ const scrollToTop = (e: React.MouseEvent) => {
+ const href = (e.currentTarget as HTMLAnchorElement).getAttribute('href');
+ if (href === '/') {
+ e.preventDefault();
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+ };
+
+ return (
+
+
+
+ {/* Links Section */}
+
+
+
+ CANVASFLOW
+
+
+ The form builder for teams who want a working form in a minute and clean data by default.
+
+
+
+
+
Directory
+ Dashboard
+ Your Forms
+ Analytics
+ Pricing
+
+
+
+
Protocol
+ Privacy Policy
+ Terms of Service
+ Security
+
+
+
+
+
+ {/* Top Huge Text */}
+
+
+ CanvasFlow—Forms
+
+
+
+ {/* Middle Huge Abstract Shapes (Cut off text) */}
+
+
+ CANVASFLOW
+
+
+
+ {/* Bottom Footer bar */}
+
+ C
+
+ Created by CanvasFlow-Studio 20—26
+
+
+
+
+
+ );
+};
+
+export default Footer;
diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx
new file mode 100644
index 0000000..63beb92
--- /dev/null
+++ b/apps/web/components/Navbar.tsx
@@ -0,0 +1,416 @@
+"use client";
+
+import React, { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { motion, AnimatePresence } from 'motion/react';
+import { ChevronDown, Menu, X } from 'lucide-react';
+import { cn } from '~/lib/utils';
+import { useGetLoggedInUserInfo } from '~/hooks/api/auth';
+
+/* ── Nav content ───────────────────────────────────────────────────────
+ Held as data rather than inline JSX so the desktop dropdowns and the
+ mobile panel render from one source. Inlining it twice is how the two
+ drift apart. */
+
+type NavLinkItem = { title: string; desc: string; href: string };
+
+const PLATFORM_GROUPS: { heading: string; items: NavLinkItem[] }[] = [
+ {
+ heading: 'Products',
+ items: [
+ { title: 'Field library', desc: 'Twelve field types, drag to reorder, edit inline', href: '#fields' },
+ { title: 'Visual form canvas', desc: 'Build, set required fields, and publish on one surface', href: '#canvas' },
+ { title: 'Response analytics', desc: 'Live dashboards the moment answers start landing', href: '#analytics' },
+ { title: 'Response stream', desc: 'Every submission in a table you can export', href: '#responses' },
+ ],
+ },
+ {
+ heading: 'Capabilities',
+ items: [
+ { title: 'One question at a time', desc: 'A focused flow with a progress bar and inline validation', href: '#how-it-works' },
+ { title: 'Share by link or QR', desc: 'Publish, copy the link, or hand over a QR code', href: '#responses' },
+ { title: 'CSV export', desc: 'Take every response with you whenever you want', href: '#analytics' },
+ { title: 'Access controls', desc: 'Close a form, set an expiry, or cap submissions', href: '#faq' },
+ ],
+ },
+];
+
+const PLATFORM_PROMO = {
+ title: 'Build together',
+ desc: 'Invite teammates with per-area roles and hand over ownership',
+ href: '#collaborate',
+};
+
+const SOLUTIONS_ITEMS: NavLinkItem[] = [
+ { title: 'For product teams', desc: 'User research and feedback loops', href: '#how-it-works' },
+ { title: 'For marketing', desc: 'Lead capture and qualification', href: '#how-it-works' },
+ { title: 'For customer success', desc: 'Satisfaction ratings and follow-ups', href: '#analytics' },
+];
+
+const RESOURCES_ITEMS: NavLinkItem[] = [
+ { title: 'Documentation', desc: 'Learn how to integrate and build', href: '#how-it-works' },
+ { title: 'Your forms', desc: 'Jump back into a draft', href: '/dashboard/sketches' },
+ { title: 'FAQ', desc: 'The short answers, fast', href: '#faq' },
+];
+
+interface NavItemProps {
+ label: string;
+ to?: string;
+ children?: React.ReactNode;
+ dropdownAlign?: 'left' | 'center' | 'right';
+}
+
+const NavItem = ({ label, to, children, dropdownAlign = 'left' }: NavItemProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const alignClass =
+ dropdownAlign === 'left' ? 'left-0' :
+ dropdownAlign === 'right' ? 'right-0' :
+ 'left-1/2 -translate-x-1/2';
+
+ return (
+ setIsOpen(true)}
+ onMouseLeave={() => setIsOpen(false)}
+ >
+ {to ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+ {children && (
+
+ )}
+
+ )}
+
+ {children && (
+
+ {isOpen && (
+
+
+ {children}
+
+
+ )}
+
+ )}
+
+ );
+};
+
+const MegaMenuItem = ({ title, desc, href }: { title: string; desc: string; href: string }) => (
+
+ {title}
+ {desc}
+
+);
+
+const SimpleDropdown = ({ items }: { items: NavLinkItem[] }) => (
+
+ {items.map((item) => (
+
+
{item.title}
+
{item.desc}
+
+ ))}
+
+);
+
+const PROMO_NOISE = `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`;
+
+const PlatformMegaMenu = () => (
+ /* Capped against the viewport as well as at a fixed width. At exactly
+ 1024px — where this menu first appears — a hard 700px panel anchored
+ under "Platform" runs past the right edge once the page gutter is
+ accounted for. */
+
+
+ {PLATFORM_GROUPS.map((group) => (
+
+
+ {group.heading}
+
+
+ {group.items.map((item) => (
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+
{PLATFORM_PROMO.title}
+
+ {PLATFORM_PROMO.desc}
+
+
+
+
+
+);
+
+/* ── Mobile panel ──────────────────────────────────────────────────────
+ The desktop dropdowns open on hover, which has no equivalent on touch,
+ so small screens get a disclosure list instead of the same menus
+ crammed into a narrower box. */
+
+const MobileSection = ({
+ label,
+ items,
+ onNavigate,
+}: {
+ label: string;
+ items: NavLinkItem[];
+ onNavigate: () => void;
+}) => {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
setOpen((v) => !v)}
+ aria-expanded={open}
+ className="flex w-full items-center justify-between py-4 text-left text-[15px] font-medium text-foreground"
+ >
+ {label}
+
+
+ {open && (
+
+ {items.map((item) => (
+
+
{item.title}
+
+ {item.desc}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+/** Scallop shape for the strip beneath the nav bar, used as a mask. */
+const WAVE_MASK =
+ `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 10'%3E%3Cpath d='M0,0 C5,10 15,10 20,0 Z' fill='%23000'/%3E%3C/svg%3E")`;
+
+const Navbar = () => {
+ const { userInfo: user } = useGetLoggedInUserInfo();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const closeMenu = () => setMenuOpen(false);
+
+ // Reaching a desktop width while the panel is open would otherwise leave
+ // it mounted underneath the restored desktop nav.
+ useEffect(() => {
+ if (!menuOpen) return;
+ const mq = window.matchMedia('(min-width: 1024px)');
+ const onChange = () => {
+ if (mq.matches) setMenuOpen(false);
+ };
+ mq.addEventListener('change', onChange);
+ return () => mq.removeEventListener('change', onChange);
+ }, [menuOpen]);
+
+ return (
+
+ {/* Blur without a saturation boost. `backdrop-saturate` lifts the
+ chroma of whatever scrolls under the bar, which works against
+ the muted band the design wants. */}
+
+
+
+ {/* Left Navigation — desktop only. The hover dropdowns it hosts
+ have no touch equivalent, so below lg the whole group is
+ replaced by the disclosure panel. */}
+
+
+
+
+
+
+
+
+
+
+ Enterprise
+
+
+
+ {/* Logo. Centred on desktop between the two nav groups; flush
+ left on mobile, where the hamburger takes the right. */}
+
+
+ CanvasFlow
+
+
+
+ {/* Right Navigation — desktop only */}
+
+
+
+
+
+
+ Pricing
+
+
+
+
+ {user ? "Dashboard" : "Log in"}
+
+
+ {user ? "Go to dashboard" : "Get started"}
+
+
+
+
+ {/* Hamburger — mobile only. The CTA lives inside the panel rather
+ than beside this button: at 320px a 117px outlined button, the
+ logo and the toggle do not fit on one line. */}
+
setMenuOpen((v) => !v)}
+ aria-label={menuOpen ? 'Close menu' : 'Open menu'}
+ aria-expanded={menuOpen}
+ aria-controls="mobile-nav-panel"
+ className="lg:hidden -mr-2 flex h-10 w-10 items-center justify-center text-foreground"
+ >
+ {menuOpen ? : }
+
+
+
+
+ {/* Mobile panel */}
+
+ {menuOpen && (
+
+ g.items)}
+ onNavigate={closeMenu}
+ />
+
+
+
+
+ Enterprise
+
+
+ Pricing
+
+
+
+
+ {user ? 'Dashboard' : 'Log in'}
+
+
+ {user ? 'Go to dashboard' : 'Get started'}
+
+
+
+ )}
+
+
+ {/* Wave bottom decoration */}
+ {/* Scalloped edge under the bar. The wave is applied as a mask and
+ filled with the nav token rather than baked into the SVG, so it
+ tracks `--hex-nav` instead of drifting out of step with it the
+ way a hardcoded fill does. */}
+
+
+
+ );
+};
+
+export default Navbar;
diff --git a/apps/web/components/Noise.tsx b/apps/web/components/Noise.tsx
new file mode 100644
index 0000000..c852954
--- /dev/null
+++ b/apps/web/components/Noise.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import React, { useRef, useEffect } from 'react';
+
+interface NoiseProps {
+ patternSize?: number;
+ patternScaleX?: number;
+ patternScaleY?: number;
+ patternRefreshInterval?: number;
+ patternAlpha?: number;
+}
+
+const Noise: React.FC = ({
+ patternSize = 300,
+ patternScaleX = 1,
+ patternScaleY = 1,
+ patternRefreshInterval = 2,
+ patternAlpha = 20
+}) => {
+ const grainRef = useRef(null);
+
+ useEffect(() => {
+ const canvas = grainRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d', { alpha: true });
+ if (!ctx) return;
+
+ let frame = 0;
+ let animationId: number;
+
+ const canvasSize = 1024;
+
+ const resize = () => {
+ if (!canvas) return;
+ canvas.width = canvasSize;
+ canvas.height = canvasSize;
+
+ canvas.style.width = '100vw';
+ canvas.style.height = '100vh';
+ };
+
+ const drawGrain = () => {
+ const imageData = ctx.createImageData(canvasSize, canvasSize);
+ const data = imageData.data;
+
+ for (let i = 0; i < data.length; i += 4) {
+ const value = Math.random() * 255;
+ data[i] = value;
+ data[i + 1] = value;
+ data[i + 2] = value;
+ data[i + 3] = patternAlpha;
+ }
+
+ ctx.putImageData(imageData, 0, 0);
+ };
+
+ const loop = () => {
+ if (frame % patternRefreshInterval === 0) {
+ drawGrain();
+ }
+ frame++;
+ animationId = window.requestAnimationFrame(loop);
+ };
+
+ window.addEventListener('resize', resize);
+ resize();
+ loop();
+
+ return () => {
+ window.removeEventListener('resize', resize);
+ window.cancelAnimationFrame(animationId);
+ };
+ }, [patternSize, patternScaleX, patternScaleY, patternRefreshInterval, patternAlpha]);
+
+ return (
+
+ );
+};
+
+export default Noise;
\ No newline at end of file
diff --git a/apps/web/components/RocketIcon.tsx b/apps/web/components/RocketIcon.tsx
new file mode 100644
index 0000000..cd63dd5
--- /dev/null
+++ b/apps/web/components/RocketIcon.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface RocketHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface RocketProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const RocketIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
+
+ const rocketVariants: Variants = {
+ normal: { y: 0, x: 0 },
+ animate: {
+ y: [0, -3, 0],
+ x: [0, 3, 0],
+ transition: {
+ duration: 0.7 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
+
+ const thrustVariants: Variants = {
+ normal: { opacity: 1, scale: 1 },
+ animate: {
+ opacity: [1, 0.3, 1],
+ scale: [1, 1.4, 1],
+ transition: {
+ duration: 0.4 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+RocketIcon.displayName = "RocketIcon";
+export { RocketIcon };
\ No newline at end of file
diff --git a/apps/web/components/ShareIcon.tsx b/apps/web/components/ShareIcon.tsx
new file mode 100644
index 0000000..f69b2a1
--- /dev/null
+++ b/apps/web/components/ShareIcon.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface ShareIconHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface ShareIconProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const ShareIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
+
+ const nodeVariants = (delay: number): Variants => ({
+ normal: {
+ scale: 1,
+ opacity: 1,
+ },
+ animate: {
+ scale: [1, 1.15, 1],
+ opacity: [0.7, 1],
+ transition: {
+ duration: 0.45 * duration,
+ ease: [0.22, 1, 0.36, 1],
+ delay,
+ },
+ },
+ });
+
+ const lineVariants: Variants = {
+ normal: {
+ opacity: 1,
+ },
+ animate: {
+ opacity: [0.4, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+);
+
+ShareIcon.displayName = "ShareIcon";
+export { ShareIcon };
\ No newline at end of file
diff --git a/apps/web/components/ShieldUserIcon.tsx b/apps/web/components/ShieldUserIcon.tsx
new file mode 100644
index 0000000..d76577a
--- /dev/null
+++ b/apps/web/components/ShieldUserIcon.tsx
@@ -0,0 +1,144 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface ShieldUserHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface ShieldUserProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const ShieldUserIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
+
+ const shieldVariants: Variants = {
+ normal: { strokeDashoffset: 0, opacity: 1 },
+ animate: {
+ strokeDashoffset: [120, 0],
+ opacity: [0.3, 1],
+ transition: { duration: 0.8 * duration, ease: "easeInOut" },
+ },
+ };
+
+ const bodyVariants: Variants = {
+ normal: { opacity: 1, y: 0 },
+ animate: {
+ opacity: [0, 1],
+ y: [6, 0],
+ transition: {
+ duration: 0.5 * duration,
+ delay: 0.5,
+ ease: "easeOut",
+ },
+ },
+ };
+
+ const headVariants: Variants = {
+ normal: { scale: 1, opacity: 1 },
+ animate: {
+ scale: [0.5, 1.2, 1],
+ opacity: [0, 1],
+ transition: {
+ duration: 0.6 * duration,
+ delay: 0.3,
+ ease: "easeOut",
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+ },
+);
+
+ShieldUserIcon.displayName = "ShieldUserIcon";
+export { ShieldUserIcon };
\ No newline at end of file
diff --git a/apps/web/components/ZapIcon.tsx b/apps/web/components/ZapIcon.tsx
new file mode 100644
index 0000000..bee6687
--- /dev/null
+++ b/apps/web/components/ZapIcon.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { cn } from "~/lib/utils";
+import type { HTMLMotionProps, Variants } from "motion/react";
+import { motion, useAnimation, useReducedMotion } from "motion/react";
+import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
+
+export interface ZapHandle {
+ startAnimation: () => void;
+ stopAnimation: () => void;
+}
+
+interface ZapProps extends HTMLMotionProps<"div"> {
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
+}
+
+const ZapIcon = forwardRef(
+ (
+ {
+ onMouseEnter,
+ onMouseLeave,
+ className,
+ size = 24,
+ duration = 1,
+ isAnimated = true,
+ ...props
+ },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
+
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () =>
+ reduced ? controls.start("normal") : controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
+
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
+
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
+
+ const bodyVariants: Variants = {
+ normal: {
+ pathLength: 1,
+ pathOffset: 0,
+ opacity: 1,
+ scale: 1,
+ rotate: 0,
+ },
+ animate: {
+ pathLength: [0, 1],
+ pathOffset: [0.6, 0],
+ opacity: [0.4, 1],
+ scale: [0.92, 1.08, 1],
+ rotate: [0, -4, 2, 0],
+ transition: {
+ pathLength: {
+ duration: 0.45 * duration,
+ ease: "easeOut",
+ },
+ pathOffset: {
+ duration: 0.45 * duration,
+ ease: "easeOut",
+ },
+ scale: {
+ duration: 0.6 * duration,
+ ease: "easeOut",
+ },
+ rotate: {
+ duration: 0.6 * duration,
+ ease: "easeOut",
+ },
+ opacity: {
+ duration: 0.25 * duration,
+ ease: "linear",
+ },
+ },
+ },
+ };
+
+ return (
+
+
+
+
+
+ );
+ },
+);
+
+ZapIcon.displayName = "ZapIcon";
+export { ZapIcon };
\ No newline at end of file
diff --git a/apps/web/components/analytics/HourlyHeatmap.tsx b/apps/web/components/analytics/HourlyHeatmap.tsx
deleted file mode 100644
index ef40413..0000000
--- a/apps/web/components/analytics/HourlyHeatmap.tsx
+++ /dev/null
@@ -1,120 +0,0 @@
-"use client";
-
-import React from "react";
-import {
- Bar,
- BarChart,
- Cell,
- ResponsiveContainer,
- Tooltip as ChartTooltip,
- XAxis,
- YAxis,
-} from "recharts";
-
-interface HourlyDistributionPoint {
- hour: number;
- count: number;
-}
-
-interface HourlyHeatmapProps {
- hourlyDistribution: HourlyDistributionPoint[];
- peakHour: number | null;
-}
-
-function formatHourLabel(hour: number): string {
- if (hour === 0) return "12a";
- if (hour === 6) return "6a";
- if (hour === 12) return "12p";
- if (hour === 18) return "6p";
- return "";
-}
-
-export function HourlyHeatmap({ hourlyDistribution, peakHour }: HourlyHeatmapProps) {
- const distributionMap = new Map(hourlyDistribution.map((d) => [d.hour, d.count]));
- const chartData = Array.from({ length: 24 }, (_, i) => ({
- hour: i,
- count: distributionMap.get(i) ?? 0,
- }));
- const totalCount = chartData.reduce((sum, d) => sum + d.count, 0);
-
- return (
-
-
-
Hours
-
Submission hours
-
- When your audience responds
-
-
-
- {totalCount === 0 ? (
-
- No submissions recorded.
-
- ) : (
-
-
-
-
-
- {
- const hour = props.payload?.hour as number;
- const label =
- hour === 0
- ? "12am"
- : hour < 12
- ? `${hour}am`
- : hour === 12
- ? "12pm"
- : `${hour - 12}pm`;
- return [value, label];
- }}
- contentStyle={{
- background: "var(--cf-cream)",
- border: "1px solid rgba(22,19,17,0.14)",
- borderRadius: "8px",
- color: "#161311",
- fontSize: 12,
- boxShadow: "0 10px 30px -12px rgba(22,19,17,0.18)",
- }}
- cursor={{ fill: "rgba(246,111,0,0.08)" }}
- />
-
- {chartData.map((entry) => {
- const isPeak = peakHour !== null && entry.hour === peakHour;
- return (
- |
- );
- })}
-
-
-
-
- )}
-
- );
-}
diff --git a/apps/web/components/landing/AnimatedCounter.tsx b/apps/web/components/landing/AnimatedCounter.tsx
new file mode 100644
index 0000000..78c0e8b
--- /dev/null
+++ b/apps/web/components/landing/AnimatedCounter.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { useAnimatedCounter } from '~/hooks/useAnimatedCounter';
+import { useScrollReveal } from '~/hooks/useScrollReveal';
+
+export function AnimatedCounter({
+ value,
+ suffix = '',
+ label,
+}: {
+ value: number;
+ suffix?: string;
+ label: string;
+}) {
+ const { ref, isVisible } = useScrollReveal(0.3);
+ const count = useAnimatedCounter(value, 1800, isVisible);
+
+ return (
+
+
+ {count.toLocaleString()}{suffix}
+
+
+ {label}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/components/landing/ArticleTrio.tsx b/apps/web/components/landing/ArticleTrio.tsx
deleted file mode 100644
index bb4d8df..0000000
--- a/apps/web/components/landing/ArticleTrio.tsx
+++ /dev/null
@@ -1,115 +0,0 @@
-"use client";
-
-import React from "react";
-import Image from "next/image";
-import { motion } from "motion/react";
-
-/**
- * Story section — Daylight's three-up "why / risks / one at a time" row,
- * reframed for CanvasFlow.
- *
- * - This is the anchor target for the nav's "Story" link (`id="story"`).
- * - Cards stagger in on scroll: each image rises and fades, the eyebrow +
- * body slide up a beat behind it.
- */
-const ARTICLES = [
- {
- eyebrow: "Why CanvasFlow",
- body: "Most form tools force a tradeoff between a tidy linear builder and the messy reality of branching logic. CanvasFlow keeps both in one warm, visual canvas.",
- img: "/new-image-4.png",
- },
- {
- eyebrow: "The brittle stack",
- body: "Renaming a field shouldn't break your webhooks. CanvasFlow locks every field to an immutable key the moment it's created, so your data layer stays stable forever.",
- img: "/new-image-5.png",
- },
- {
- eyebrow: "One form at a time",
- body: "Every published form makes the studio more useful: shared field templates, reusable answer routes, and analytics that compound across your projects.",
- img: "/new-image-6.png",
- },
-];
-
-const EASE = [0.22, 1, 0.36, 1] as const;
-
-export function ArticleTrio() {
- return (
-
-
-
- {ARTICLES.map((a, i) => (
-
- {/* image */}
-
-
-
-
- {/* heading */}
-
- {a.eyebrow}
-
-
- {/* body */}
-
- {a.body}
-
-
- ))}
-
-
-
- );
-}
diff --git a/apps/web/components/landing/CanvasFlowMockup.tsx b/apps/web/components/landing/CanvasFlowMockup.tsx
new file mode 100644
index 0000000..e0f3002
--- /dev/null
+++ b/apps/web/components/landing/CanvasFlowMockup.tsx
@@ -0,0 +1,603 @@
+"use client";
+
+import { useState, useRef, type CSSProperties } from 'react';
+import { motion, useInView } from 'motion/react';
+
+export const GeoGlyph = ({ className = '' }: { className?: string }) => (
+
+);
+
+const SERIES = [
+ { name: 'Onboarding', color: 'var(--c-orange)', pts: [32, 36, 35, 41, 44, 42, 47] },
+ { name: 'Pricing', color: 'var(--c-yellow)', pts: [28, 32, 30, 36, 39, 41, 40] },
+ { name: 'Churn', color: 'var(--c-teal)', pts: [26, 28, 31, 30, 33, 35, 34] },
+ { name: 'Signup', color: 'var(--c-blue)', pts: [22, 24, 27, 26, 28, 27, 30] },
+ { name: 'Research', color: 'var(--c-purple)', pts: [16, 18, 17, 20, 19, 21, 22] },
+ { name: 'Beta', color: 'var(--c-lavender)', pts: [9, 10, 11, 11, 12, 12, 13] },
+];
+
+const MultiLineChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
+ const padL = 28, padR = 8, padT = 8, padB = 22;
+ const innerW = w - padL - padR;
+ const innerH = h - padT - padB;
+ const max = 50;
+ const basePts = SERIES[0]?.pts ?? [];
+ const xs = basePts.map((_, i) => padL + (i * innerW) / (basePts.length - 1));
+ const yFor = (v: number) => padT + innerH - (v / max) * innerH;
+ const [hover, setHover] = useState(null);
+ const labels = ['Q1', '', '', 'Q2', '', '', 'Q3'];
+ const yTicks = [0, 10, 20, 30, 40, 50];
+
+ return (
+
+ {yTicks.map((t) => (
+
+
+ {t}
+
+ ))}
+ {SERIES.map((s) => {
+ const d = s.pts.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${yFor(v)}`).join(' ');
+ const dim = hover && hover !== s.name;
+ return (
+ setHover(s.name)}
+ onMouseLeave={() => setHover(null)}
+ style={{ transition: 'opacity .2s', opacity: dim ? 0.18 : 1, cursor: 'pointer' }}>
+
+ {s.pts.map((v, i) => (
+
+ ))}
+
+ );
+ })}
+ {labels.map((l, i) => l && (
+ {l}
+ ))}
+
+ );
+};
+
+const SCATTER_GROUPS = [
+ { color: 'var(--c-purple)', n: 36, cx: 0.18, cy: 0.55, spread: 0.12 },
+ { color: 'var(--c-teal)', n: 42, cx: 0.45, cy: 0.40, spread: 0.16 },
+ { color: 'var(--c-yellow)', n: 40, cx: 0.78, cy: 0.62, spread: 0.14 },
+];
+
+const ScatterChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
+ const padL = 28, padR = 8, padT = 8, padB = 22;
+ const innerW = w - padL - padR;
+ const innerH = h - padT - padB;
+ const yTicks = [0, 5, 10, 15, 20, 25];
+ const rng = (seed: number) => {
+ const x = Math.sin(seed) * 10000;
+ return x - Math.floor(x);
+ };
+ const dots: { x: number; y: number; c: string; d: number }[] = [];
+ let seed = 1;
+ SCATTER_GROUPS.forEach((g) => {
+ for (let i = 0; i < g.n; i++) {
+ const dx = (rng(seed++) - 0.5) * 2 * g.spread;
+ const dy = (rng(seed++) - 0.5) * 2 * g.spread;
+ dots.push({
+ x: padL + (g.cx + dx) * innerW,
+ y: padT + (g.cy + dy) * innerH,
+ c: g.color,
+ d: rng(seed++) * 0.6,
+ });
+ }
+ });
+ return (
+
+ {yTicks.map((t) => (
+
+
+ {t}
+
+ ))}
+ {dots.map((d, i) => (
+
+ ))}
+ {[0, 10, 20, 30, 40, 50].map((v, i) => (
+ {v}
+ ))}
+
+ );
+};
+
+const STACK_ROWS = [
+ { label: 'Onboarding flow', parts: [42, 18, 24, 16] },
+ { label: 'Pricing page', parts: [38, 22, 22, 18] },
+ { label: 'Churn survey', parts: [30, 26, 28, 16] },
+ { label: 'Dark mode poll', parts: [22, 28, 30, 20] },
+];
+const STACK_COLORS = ['var(--c-red)', 'var(--c-yellow)', 'var(--c-teal)', 'var(--c-purple)'];
+
+const StackedBars = () => (
+
+ {STACK_ROWS.map((r, ri) => {
+ const total = r.parts.reduce((a, b) => a + b, 0);
+ return (
+
+
{r.label}
+
+ {r.parts.map((p, i) => (
+
+ ))}
+
+
+ );
+ })}
+
+);
+
+export const DashboardMock = () => {
+ const [tab, setTab] = useState('Summary');
+ const tabs = ['Summary', 'Responses', 'Drop-off', 'Segments'];
+ const ref = useRef(null);
+ const isInView = useInView(ref, { once: true, margin: "-100px" });
+
+ return (
+
+
+
+ CanvasFlow · Onboarding Survey
+
+
+ Edit
+ Share
+
+
+
+
+
Onboarding Survey · Q3 Overview
+
+ Live breakdown of responses across product areas, segments, and time.
+
+
+ {/* Tabs scroll rather than wrap. Wrapping a tab strip onto a second
+ line reads as broken chrome; sliding it is what real UI does. */}
+
+ {tabs.map((t) => (
+
setTab(t)}>{t}
+ ))}
+
+
+
+ {[
+ { v: '1,284', l: 'Total responses', sub: '↑ 9.7% vs last week' },
+ { v: '87%', l: 'Completion rate', sub: '↑ 4.1% vs last week' },
+ { v: '2:14', l: 'Avg. time', sub: '↓ 0:18 vs last week' },
+ ].map((k) => (
+
+
{k.v}
+
{k.l}
+ {/* The delta is the first thing to go: three of these side by
+ side at phone width leaves no room for a second line of
+ supporting text under each figure. */}
+
{k.sub}
+
+ ))}
+
+
+
+ {[
+ { l: 'Form', v: 'All' },
+ { l: 'Segment', v: 'All' },
+ { l: 'Source', v: 'All' },
+ { l: 'Quarter', v: 'Q3' },
+ ].map((f) => (
+
+
{f.l}
+
+ {f.v}
+ ▾
+
+
+ ))}
+
+
+ {/* Two charts side by side gives each about 150px on a phone, which
+ is narrower than their own axis labels. They stack instead. */}
+
+
+
Responses by form · Q1–Q3
+
+
+ {SERIES.map((s) => (
+
+ {s.name}
+
+ ))}
+
+
+
+
Completion vs time-to-finish
+
+
+ {['Mobile', 'Desktop', 'Embed'].map((g, i) => (
+
+ {g}
+
+ ))}
+
+
+
+
+
+
Answers by question
+
+
+ {['Loved it', 'Neutral', 'Friction', 'Confused'].map((g, i) => (
+
+ {g}
+
+ ))}
+
+
+
+
+ );
+};
+
+export const FormBuilderMock = () => {
+ const [active, setActive] = useState(0);
+ return (
+
+
+
+
+
+
+
+
+
canvasflow.app/builder
+
+
+ Draft
+ {/* Redundant next to "Draft" once space is tight. */}
+ autosaved · 2s ago
+
+
+ {/* The block palette is a 160px fixed column. Below sm it becomes a
+ horizontal strip above the canvas, so the canvas keeps the full
+ width for the questions that are the point of the mock. */}
+
+
+
+
+
+
Q1 · Short text
+
What’s the biggest pain in your current workflow?
+
Type your answer…
+
+
+
+
+
+
+
Q2 · Multiple choice
+
How often does this friction occur?
+
+ {['Every single day','A few times per week','Only during month-end'].map((opt,i)=>(
+
+ ))}
+
+
+
+
+
+ );
+};
+
+/* ── Live responses feed ───────────────────────────────────────────── */
+
+const FEED_ROWS = [
+ { name: 'Aman R.', ago: 'just now', tag: 'Daily', c: 'var(--c-red)' },
+ { name: 'Priya M.', ago: '12s ago', tag: 'Weekly', c: 'var(--c-yellow)' },
+ { name: 'Jordan K.', ago: '48s ago', tag: 'Daily', c: 'var(--c-red)' },
+ { name: 'Sara L.', ago: '1m ago', tag: 'Rarely', c: 'var(--c-teal)' },
+ { name: 'Marc T.', ago: '2m ago', tag: 'Weekly', c: 'var(--c-yellow)' },
+];
+
+export const ResponseFeedMock = () => (
+
+
+
+ Live responses
+
+ 1,284
+
+
+
+ {/* Two stacked spans: the outer one pulses as a halo while the
+ inner dot stays solid, so the indicator reads as a signal
+ rather than the whole dot fading in and out. */}
+
+
+
+
+ streaming
+
+
+
+
+ {FEED_ROWS.map((r, i) => (
+
+ {i === 0 && (
+
+ )}
+
+
+ {r.name[0]}
+
+
+
{r.name}
+
+ {r.ago}
+
+
+
+ {/* Tinted pill rather than a solid fill. A saturated block of
+ colour with white type fights the muted paper palette; a wash
+ of the same hue keeps the category legible and calm. */}
+
+ {r.tag}
+
+
+ ))}
+
+
+
+ 5 of 1,284
+ response_id #f8a2…
+
+
+);
+
+export const AnalyticsMock = DashboardMock;
+
+export const CanvasEditorMock = () => {
+ const [tab, setTab] = useState('Build');
+ const tabs = ['Build', 'Fields', 'Share', 'Settings'];
+ return (
+
+
+
+ CanvasFlow · Customer Feedback Form
+
+
+ Preview
+ Publish
+
+
+
+
+
Form Builder · Interactive Canvas
+
+ Drop in fields, drag to reorder, mark what’s required, then publish.
+
+
+
+ {tabs.map((t) => (
+
setTab(t)}>{t}
+ ))}
+
+
+
+ {[
+ { v: '12', l: 'Fields', sub: 'Drag to reorder' },
+ { v: '4', l: 'Required', sub: 'Validated on submit' },
+ { v: '2m', l: 'Est. Time', sub: 'Average completion' },
+ ].map((k) => (
+
+
{k.v}
+
{k.l}
+
{k.sub}
+
+ ))}
+
+
+ {/* The stage is a fixed 420px tall on desktop. On a phone the tool
+ rail and inspector are gone, so the canvas alone needs less
+ height and a shorter box avoids a tall band of empty grid. */}
+
+
+
+ {/* Tool rail: 56px of icons with no labels. Dropping it below sm
+ gives the canvas the room, and nothing it conveys is lost. */}
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ {i === 1 &&
}
+ {i === 2 &&
}
+ {i === 3 &&
}
+ {i === 4 &&
}
+ {i === 5 &&
}
+
+ ))}
+
+
+
+
+
+
+
+ 01
+
+ Rating · required
+ How likely are you to recommend CanvasFlow?
+
+ {[1, 2, 3, 4, 5].map(n => (
+
+ {n}
+
+ ))}
+
+
+
+
+
+
+
+ 02
+
+
+
Long text · optional
+
+
+ What is the primary reason for your score?
+
+ Type your reason here...
+
+
+
+
+
+
+ {/* Properties panel: 272px fixed. Kept off until md — at sm the
+ rail and this panel together would leave the canvas narrower
+ than the field cards it is meant to be showing. */}
+
+
+
+
+
Component ID
+
q_reason_text
+
+
+
+
Field settings
+
+ {[
+ { k: 'Type', v: 'Long text' },
+ { k: 'Required', v: 'Off' },
+ { k: 'Placeholder', v: 'Type your reason…' },
+ ].map((row) => (
+
+ {row.k}
+ {row.v}
+
+ ))}
+
+
+
+
+
+ Save field
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/apps/web/components/landing/Comparison.tsx b/apps/web/components/landing/Comparison.tsx
new file mode 100644
index 0000000..531f5f2
--- /dev/null
+++ b/apps/web/components/landing/Comparison.tsx
@@ -0,0 +1,312 @@
+"use client";
+
+import { useRef, useState, type MouseEvent } from 'react';
+import { ScrollReveal } from './ScrollReveal';
+import { cn } from '~/lib/utils';
+import { motion, useMotionValue, useSpring, useTransform } from 'motion/react';
+import {
+ Palette,
+ BarChart3,
+ Search,
+ ShieldCheck,
+ Smile,
+ Check,
+ X,
+ Terminal,
+ Globe,
+ type LucideIcon,
+} from 'lucide-react';
+
+type CategoryId = 'ai' | 'design' | 'intel' | 'seo' | 'security' | 'vibe';
+
+const CATEGORIES: { id: CategoryId; label: string; icon: LucideIcon }[] = [
+ { id: 'ai', label: 'AI Intelligence', icon: Terminal },
+ { id: 'design', label: 'Design', icon: Palette },
+ { id: 'intel', label: 'Analytics', icon: BarChart3 },
+ { id: 'seo', label: 'SEO', icon: Search },
+ { id: 'security', label: 'Security', icon: ShieldCheck },
+ { id: 'vibe', label: 'Experience', icon: Smile },
+];
+
+interface FeatureRow {
+ feature: string;
+ aqora: boolean | string;
+ google: boolean | string;
+ typeform: boolean | string;
+}
+
+const COMPARISON_DATA: Record<
+ CategoryId,
+ { summary: { aqora: string; others: string }; features: FeatureRow[] }
+> = {
+ ai: {
+ summary: {
+ aqora:
+ 'Deep neural integration with AI Forge, automated protocol generation, and real-time response prediction.',
+ others: 'Legacy form builders with no AI integration. All logic and structure must be built manually.',
+ },
+ features: [
+ { feature: 'AI Forge (Prompt-to-Form)', aqora: true, google: false, typeform: false },
+ { feature: 'Neural Response Analysis', aqora: true, google: false, typeform: false },
+ { feature: 'Adaptive UI Evolution', aqora: true, google: false, typeform: false },
+ { feature: 'Automated Data Correlation', aqora: true, google: false, typeform: false },
+ { feature: 'Predictive Smart-Logic', aqora: true, google: false, typeform: false },
+ ],
+ },
+ design: {
+ summary: {
+ aqora: 'Brutalist design system with 15+ themes, custom fonts, and full visual control.',
+ others: 'Limited templates with no design customization or theme switching.',
+ },
+ features: [
+ { feature: '15+ built-in themes', aqora: true, google: false, typeform: '3 basic' },
+ { feature: 'Custom font & color control', aqora: true, google: false, typeform: 'Paid' },
+ { feature: 'Background patterns', aqora: true, google: false, typeform: false },
+ { feature: 'Card opacity & shadow depth', aqora: true, google: false, typeform: false },
+ { feature: 'Conversational layout mode', aqora: true, google: false, typeform: true },
+ ],
+ },
+ intel: {
+ summary: {
+ aqora: 'Deep analytics with survey, research, and data-work intelligence modes.',
+ others: 'Basic response viewing with manual CSV export required for analysis.',
+ },
+ features: [
+ { feature: 'Real-time response dashboard', aqora: true, google: false, typeform: true },
+ { feature: 'Mean, median, std deviation', aqora: true, google: false, typeform: false },
+ { feature: 'Consensus detection', aqora: true, google: false, typeform: false },
+ { feature: 'Score distribution charts', aqora: true, google: 'Basic', typeform: 'Paid' },
+ { feature: 'Multiple analysis modes', aqora: '3 modes', google: false, typeform: false },
+ ],
+ },
+ seo: {
+ summary: {
+ aqora: 'Full SEO control with meta tags, social previews, and indexing options.',
+ others: 'Zero SEO capabilities. Forms are hidden from search engines entirely.',
+ },
+ features: [
+ { feature: 'Custom meta title & description', aqora: true, google: false, typeform: 'Basic' },
+ { feature: 'SEO keywords support', aqora: true, google: false, typeform: false },
+ { feature: 'Indexability toggle', aqora: true, google: false, typeform: false },
+ { feature: 'Social sharing previews', aqora: true, google: false, typeform: false },
+ { feature: 'Custom form URLs', aqora: true, google: false, typeform: 'Paid' },
+ ],
+ },
+ security: {
+ summary: {
+ aqora: 'Password protection, domain restrictions, and submission limits built-in.',
+ others: 'Basic authentication tied to workspace. Advanced security is paywalled.',
+ },
+ features: [
+ { feature: 'Password-protected forms', aqora: true, google: false, typeform: 'Paid' },
+ { feature: 'Email domain restriction', aqora: true, google: 'GSuite only', typeform: 'Paid' },
+ { feature: 'Submission limit control', aqora: true, google: false, typeform: 'Paid' },
+ { feature: 'Form close date scheduling', aqora: true, google: true, typeform: true },
+ { feature: 'Row-level security (RLS)', aqora: true, google: false, typeform: false },
+ ],
+ },
+ vibe: {
+ summary: {
+ aqora: 'Edgy validation messages, quiz mode with scoring, and confetti celebrations.',
+ others: 'Generic error messages. Quiz features limited or require paid plans.',
+ },
+ features: [
+ { feature: 'Quiz mode with scoring', aqora: true, google: true, typeform: 'Paid' },
+ { feature: 'Custom confirmation messages', aqora: true, google: 'Basic', typeform: true },
+ { feature: 'Conditional logic branching', aqora: true, google: false, typeform: true },
+ { feature: 'Section headers & descriptions', aqora: true, google: true, typeform: true },
+ { feature: 'Response time tracking', aqora: true, google: false, typeform: false },
+ ],
+ },
+};
+
+const FeatureCell = ({ value }: { value: boolean | string }) => {
+ if (value === true) return ;
+ if (value === false) return ;
+ return {value} ;
+};
+
+export const ComparisonSection = () => {
+ const [activeCategory, setActiveCategory] = useState('ai');
+ const containerRef = useRef(null);
+ const data = COMPARISON_DATA[activeCategory];
+
+ const mouseX = useMotionValue(0);
+ const mouseY = useMotionValue(0);
+ const springConfig = { damping: 25, stiffness: 200 };
+ const rotateX = useSpring(useTransform(mouseY, [-0.5, 0.5], [5, -5]), springConfig);
+ const rotateY = useSpring(useTransform(mouseX, [-0.5, 0.5], [-5, 5]), springConfig);
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!containerRef.current) return;
+ const rect = containerRef.current.getBoundingClientRect();
+ mouseX.set((e.clientX - rect.left) / rect.width - 0.5);
+ mouseY.set((e.clientY - rect.top) / rect.height - 0.5);
+ };
+
+ const handleMouseLeave = () => {
+ mouseX.set(0);
+ mouseY.set(0);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ FEATURE COMPARISON MATRIX
+
+
+
+ CANVASFLOW {' '}
+ VS {' '}
+ THE REST
+
+
+
+
+
+ AN HONEST, FEATURE-BY-FEATURE BREAKDOWN. NO MARKETING FLUFF.
+
+
+
+
+
+
+ {CATEGORIES.map((cat) => {
+ const isActive = activeCategory === cat.id;
+ const Icon = cat.icon;
+ return (
+ setActiveCategory(cat.id)}
+ className={cn(
+ 'flex items-center gap-3 px-6 py-3 border-2 transition-all text-left font-mono',
+ isActive
+ ? 'bg-accent text-white border-accent shadow-[0_0_30px_rgba(255,69,0,0.2)]'
+ : 'border-white/10 text-white/40 hover:text-white hover:border-white/30 bg-white/2'
+ )}
+ >
+
+ {cat.label}
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ CANVASFLOW APPROACH
+
+
+
{data.summary.aqora}
+
+
+
+
+
+ COMPETITORS
+
+
+
{data.summary.others}
+
+
+
+
+
+
+
+
+ FEATURE
+
+
+ CANVASFLOW
+
+
+ GOOGLE
+
+
+ TYPEFORM
+
+
+
+ {data.features.map((row, idx) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ BUILT DIFFERENT
+
+
+ EVERY FEATURE. INCLUDED.
+
+
+
+
+
+ {[
+ { num: '15+', label: 'THEMES' },
+ { num: '3', label: 'INTEL MODES' },
+ { num: '∞', label: 'FORMS' },
+ ].map((stat) => (
+
+
{stat.num}
+
{stat.label}
+
+ ))}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/apps/web/components/landing/CutDropOff.tsx b/apps/web/components/landing/CutDropOff.tsx
deleted file mode 100644
index 12edda2..0000000
--- a/apps/web/components/landing/CutDropOff.tsx
+++ /dev/null
@@ -1,136 +0,0 @@
-"use client";
-
-import React from "react";
-import Image from "next/image";
-import { motion } from "motion/react";
-
-/**
- * "Cut form drop-off by 40% or more" — a Daylight-style cream-paper section
- * built as a 3x2 image-and-empty-cell grid, with the headline centered in the
- * top-middle cell.
- *
- * On scroll:
- * - The eyebrow + first headline line ("Cut form drop-off") fade in first.
- * - The second line ("by 40% or more") fades in after a short delay.
- * - The two photo cells slide in from their outer edges.
- */
-
-const EASE = [0.22, 1, 0.36, 1] as const;
-
-export function CutDropOff() {
- return (
-
-
- {/* horizontal divider line at the top, like Daylight */}
-
-
-
- {/* row 1, col 1: empty cream cell (desktop spacer) */}
-
-
- {/* row 1, col 2: headline */}
-
-
- Save
-
-
-
- Cut form drop-off
-
-
- by 40% or more
-
-
-
-
- {/* row 1, col 3: hero image (slides in from the right) */}
-
-
-
-
- {/* row 2, col 1: image (slides in from the left) */}
-
-
-
-
- {/* row 2, col 2: empty */}
-
-
- {/* row 2, col 3: empty */}
-
-
-
-
- );
-}
diff --git a/apps/web/components/landing/FooterSunset.tsx b/apps/web/components/landing/FooterSunset.tsx
deleted file mode 100644
index 4de4718..0000000
--- a/apps/web/components/landing/FooterSunset.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-"use client";
-
-import React from "react";
-import Image from "next/image";
-
-/**
- * Daylight-style footer:
- * - Cream paper background
- * - Logo wordmark on the left
- * - Huge serif "More Forms" wordmark
- * - Copyright line at the bottom
- */
-export function FooterSunset() {
- return (
-
-
-
-
-
- CanvasFlow
-
-
-
-
- More Forms
-
-
- {/* small editorial colophon to balance the right side */}
-
- Colophon
-
- A studio for makers of beautiful data. Drafted in cream paper, shipped on the open
- canvas.
-
-
-
-
-
-
- Studio · live
-
-
-
-
-
- © 2026 CanvasFlow Studio. Built for makers of beautiful data.
-
-
-
-
- );
-}
diff --git a/apps/web/components/landing/HeroSunset.tsx b/apps/web/components/landing/HeroSunset.tsx
deleted file mode 100644
index 4bd0bd8..0000000
--- a/apps/web/components/landing/HeroSunset.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import { Snowflake } from "lucide-react";
-
-/**
- * Daylight-style hero:
- * - Full-bleed dark warm "golden-hour" backdrop (CSS-only, photo-feel)
- * - Left content: small left gutter, vertically centered. Tiny uppercase
- * eyebrow, huge serif headline, short sub-paragraph, address-style
- * input pill with a dark CTA.
- * - Right side: a solid-orange "energy panel" tile, vertically centered and
- * docked to the right gutter. Stat rows sit above and below it. The only
- * blueprint guide lines on the page are the 4 lines that exactly trace
- * this panel's bounding box, extended off-screen to the viewport edges.
- */
-export function HeroSunset({ isLoggedIn }: { isLoggedIn: boolean }) {
- return (
-
- {/* photo-feel layered shadows */}
-
-
-
-
-
- {/* ---- Left: headline + input (vertically centered, hugging the left gutter) ---- */}
-
-
Forms you control
-
- Build forms
- in golden hour.
-
-
- Drag and drop builder, durable data keys, real-time analytics. Free to start — no credit
- card required.
-
-
-
- {isLoggedIn ? "Open dashboard" : "Start building"}
-
-
-
- {/* ---- Right: orange "energy panel" — vertically centered, docked to the right gutter ---- */}
-
-
-
- );
-}
-
-function RightPanel() {
- return (
-
- {/* ---- The only grid lines on the page: trace the orange tile's 4 edges
- and extend off-screen to the viewport edges. ---- */}
-
- {/* horizontals along the orange tile's top & bottom */}
-
-
- {/* verticals along the orange tile's left & right edges */}
-
-
-
-
- {/* top stat: "Responses today" */}
-
-
-
Responses today
-
2,418 · +12.4%
-
-
-
- {/* the vivid orange tile */}
-
-
- {/* bottom: split labels */}
-
-
-
Backup stored
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/web/components/landing/HowItWorks.tsx b/apps/web/components/landing/HowItWorks.tsx
deleted file mode 100644
index 76c8ac1..0000000
--- a/apps/web/components/landing/HowItWorks.tsx
+++ /dev/null
@@ -1,218 +0,0 @@
-"use client";
-
-import React, { useRef } from "react";
-import Link from "next/link";
-import { ArrowUpRight } from "lucide-react";
-import { motion, useInView } from "motion/react";
-
-/**
- * Daylight-style "How it works" section:
- * - Cream background, large editorial serif headline on the left
- * - 3 stacked "step" cards on the right with eyebrow + title + body
- * - Scroll-triggered animations: left intro fades + slides up, step cards
- * stagger up as they enter view, the chart's bars grow from the baseline.
- */
-const STEPS = [
- {
- n: "step 1",
- h: "Open the canvas",
- s: "A canvas you can sketch on",
- body: "Drag fields onto an open canvas, connect them like nodes, and watch logic appear in the same place as layout.",
- cta: { label: "Get your estimate", href: "/signUp" },
- },
- {
- n: "Step 2",
- h: "We handle the wiring",
- s: "CanvasFlow handles everything",
- body: "Field keys are slugged into permanent identifiers the moment you create them. Rename labels freely; your endpoints, exports, and webhooks keep flowing.",
- },
- {
- n: "Step 3",
- h: "Publish & measure",
- s: "Responses you control",
- body: "Share a single link. Submissions, devices, and conversion rates light up your dashboard the instant they arrive.",
- chart: true,
- },
-];
-
-// Default ease used across this section — smooth, slightly snappy.
-const EASE = [0.22, 1, 0.36, 1] as const;
-
-export function HowItWorks({ isLoggedIn }: { isLoggedIn: boolean }) {
- return (
-
-
-
-
- {/* LEFT: title + lead (fades + slides up as it enters view) */}
-
- How CanvasFlow Works
-
- A new way to build
- your forms
-
-
- With CanvasFlow, your form generates and stores responses with durable keys and
- real-time analytics. We cover the infrastructure and upfront setup. You get instant
- publishing and below-spreadsheet pricing.
-
-
- See if your form qualifies
-
-
-
-
- {/* RIGHT: stacked step cards (each animates in on scroll) */}
-
- {STEPS.map((step, i) => (
-
-
-
-
{step.n}
-
- {step.h}
-
-
- {step.s}
-
-
- {step.body}
-
- {step.cta && (
-
- {step.cta.label}
-
-
- )}
-
-
- 0{i + 1}
-
-
-
- {step.chart && }
-
- ))}
-
-
-
-
- );
-}
-
-function PageGuides() {
- return (
-
- );
-}
-
-function StepChart() {
- // weekday bar chart — bars grow from the baseline once it enters the viewport.
- const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
- const heights = [22, 38, 30, 52, 44, 60, 35];
- const maxH = 64; // px — track height of the chart row
-
- const ref = useRef(null);
- const inView = useInView(ref, { once: true, amount: 0.4 });
-
- return (
-
-
-
Response Earnings
-
- +12.4%
-
-
-
-
- 2,418
-
-
- responses
-
-
-
-
- {heights.map((h, i) => (
-
- ))}
-
-
- {days.map((d, i) => (
-
- {d}
-
- ))}
-
-
- );
-}
diff --git a/apps/web/components/landing/IntellegentExecution.tsx b/apps/web/components/landing/IntellegentExecution.tsx
new file mode 100644
index 0000000..5c58990
--- /dev/null
+++ b/apps/web/components/landing/IntellegentExecution.tsx
@@ -0,0 +1,161 @@
+const Badge = ({ text, className = '' }: { text: string; className?: string }) => (
+
+ ↳ {text}
+
+);
+
+export const IntelligentExecutionGraph = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Logic Router
+
+
+ Rule 1
+
+ SCORE < 5
+
+
+
+ Rule 2
+
+ SENTIMENT
+
+
+
+ Default
+
+ CONTINUE
+
+
+
+
+
+
+
+
Validation Check
+
+
+ SCORE < {' '}
+ 5
+
+
+ AND SENTIMENT {' '}
+ is Negative
+
+
+
+
+
+
+
AI Prompt Engine
+
+ generate follow_up_questions
+
+ from (
+ context session_data){' '}
+
+ where sentiment = ((
+ negative
+ ))
+
+ using prompt_template({"'churn_risk'"} )
+
+
+
+
+
+
User Input
+
+ Very Dissatisfied
+ ▼
+
+
+
+
+
+
+ SYNC DESTINATIONS
+
+
+
+ S
+
+
+ B
+
+
+ A
+
+
+ D
+
+
+ X
+
+
+
And more...
+
+
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/apps/web/components/landing/LandingNavSunset.tsx b/apps/web/components/landing/LandingNavSunset.tsx
deleted file mode 100644
index cecd324..0000000
--- a/apps/web/components/landing/LandingNavSunset.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import Image from "next/image";
-
-/**
- * Daylight-style floating navigation:
- * - Centered white pill, 8px radius, soft shadow + 1px hairline ring
- * - Plain text links (no underline / no chips)
- * - Single flat solid-orange "Get started" CTA on the right
- */
-export function LandingNavSunset({ isLoggedIn }: { isLoggedIn: boolean }) {
- return (
-
-
-
-
-
- CanvasFlow
-
-
-
-
- {[
- { label: "Product", href: "#how-it-works" },
- { label: "Story", href: "#story" },
- { label: "About", href: "#about" },
- { label: "Brand", href: "#brand" },
- ].map((item) => (
-
- {item.label}
-
- ))}
-
-
-
- {isLoggedIn ? "Dashboard" : "Get started"}
-
-
-
- );
-}
diff --git a/apps/web/components/landing/NetworkSection.tsx b/apps/web/components/landing/NetworkSection.tsx
deleted file mode 100644
index 8dc4b86..0000000
--- a/apps/web/components/landing/NetworkSection.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import { ArrowUpRight } from "lucide-react";
-
-/**
- * Daylight's "A growing network — Every home makes the network stronger" block,
- * reframed for CanvasFlow: every published form strengthens the shared studio.
- */
-export function NetworkSection({ isLoggedIn }: { isLoggedIn: boolean }) {
- return (
-
-
-
A growing studio
-
- Every form makes
- the studio stronger
-
-
- CanvasFlow forms generate, store, and share fields locally to your workspace. Together
- they form a studio that scales with every new template and route you publish.
-
-
- See if your form qualifies
-
-
-
-
- );
-}
diff --git a/apps/web/components/landing/ScrollReveal.tsx b/apps/web/components/landing/ScrollReveal.tsx
new file mode 100644
index 0000000..eb31f43
--- /dev/null
+++ b/apps/web/components/landing/ScrollReveal.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import React from 'react';
+import { useScrollReveal } from '~/hooks/useScrollReveal';
+import { cn } from '~/lib/utils';
+
+type Direction = 'up' | 'down' | 'left' | 'right' | 'none';
+
+const directionStyles: Record = {
+ up: 'translate-y-12',
+ down: '-translate-y-12',
+ left: 'translate-x-12',
+ right: '-translate-x-12',
+ none: '',
+};
+
+interface ScrollRevealProps {
+ children: React.ReactNode;
+ direction?: Direction;
+ delay?: number;
+ className?: string;
+}
+
+export function ScrollReveal({
+ children,
+ direction = 'up',
+ delay = 0,
+ className,
+}: ScrollRevealProps) {
+ const { ref, isVisible } = useScrollReveal(0.15);
+
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/components/landing/StepIntoCanvasFlow.tsx b/apps/web/components/landing/StepIntoCanvasFlow.tsx
deleted file mode 100644
index e7869c0..0000000
--- a/apps/web/components/landing/StepIntoCanvasFlow.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import Image from "next/image";
-
-/**
- * Daylight's giant "Step into Daylight" hero-finale block, restated for us.
- * Big serif title, centered logo, an editorial paragraph, then a CTA.
- */
-export function StepIntoCanvasFlow({ isLoggedIn }: { isLoggedIn: boolean }) {
- return (
-
-
-
- Step into
- CanvasFlow
-
-
-
-
-
-
-
- The canvas is bright
-
-
- Ready to take control of your forms? It's time to step out of spreadsheet templates
- and into a more resilient, real-time data future.
-
-
- {isLoggedIn ? "Open dashboard" : "See if you qualify"}
-
-
-
-
- );
-}
diff --git a/apps/web/components/landing/TrackInApp.tsx b/apps/web/components/landing/TrackInApp.tsx
deleted file mode 100644
index efe9a73..0000000
--- a/apps/web/components/landing/TrackInApp.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-"use client";
-
-import React from "react";
-import Image from "next/image";
-
-/**
- * "Track every submission" section — Daylight's "Track it all in the app" twin.
- * - Cream background, centered serif headline
- * - A right-hand desk illustration to keep the editorial scene
- * - Bottom area is intentionally airy (paper-like), like the reference
- */
-export function TrackInApp() {
- return (
-
-
-
- {/* left: empty paper column (desktop only) */}
-
-
- {/* center: text */}
-
-
Control
-
- Track it all in the
- CanvasFlow app
-
-
-
- {/* right: desk illustration */}
-
-
-
-
-
- {/* a quieter under-row to keep the airy paper feel (desktop only) */}
-
-
-
- );
-}
diff --git a/apps/web/components/landing/TypeWritterText.tsx b/apps/web/components/landing/TypeWritterText.tsx
new file mode 100644
index 0000000..d2a8ba0
--- /dev/null
+++ b/apps/web/components/landing/TypeWritterText.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useState, useEffect } from 'react';
+
+export function TypewriterText({
+ texts,
+ speed = 80,
+ deleteSpeed = 40,
+ pauseDuration = 2000,
+ className,
+}: {
+ texts: string[];
+ speed?: number;
+ deleteSpeed?: number;
+ pauseDuration?: number;
+ className?: string;
+}) {
+ const [textIndex, setTextIndex] = useState(0);
+ const [charIndex, setCharIndex] = useState(0);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ useEffect(() => {
+ const currentText = texts[textIndex] ?? '';
+
+ if (!isDeleting && charIndex === currentText.length) {
+ const timeout = setTimeout(() => setIsDeleting(true), pauseDuration);
+ return () => clearTimeout(timeout);
+ }
+
+ if (isDeleting && charIndex === 0) {
+ setIsDeleting(false);
+ setTextIndex((prev) => (prev + 1) % texts.length);
+ return;
+ }
+
+ const timeout = setTimeout(
+ () => setCharIndex((prev) => prev + (isDeleting ? -1 : 1)),
+ isDeleting ? deleteSpeed : speed
+ );
+ return () => clearTimeout(timeout);
+ }, [charIndex, isDeleting, textIndex, texts, speed, deleteSpeed, pauseDuration]);
+
+ return (
+
+ {(texts[textIndex] ?? '').substring(0, charIndex)}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/components/ui/accordion.tsx b/apps/web/components/ui/accordion.tsx
deleted file mode 100644
index a0cb8ff..0000000
--- a/apps/web/components/ui/accordion.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import * as React from "react";
-import * as AccordionPrimitive from "@radix-ui/react-accordion";
-import { ChevronDownIcon } from "lucide-react";
-
-import { cn } from "~/lib/utils";
-
-function Accordion({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function AccordionItem({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AccordionTrigger({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
- svg]:rotate-180",
- className,
- )}
- {...props}
- >
- {children}
-
-
-
- );
-}
-
-function AccordionContent({
- className,
- children,
- ...props
-}: React.ComponentProps) {
- return (
-
- {children}
-
- );
-}
-
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/apps/web/components/ui/alert-dialog.tsx b/apps/web/components/ui/alert-dialog.tsx
deleted file mode 100644
index 1f9c398..0000000
--- a/apps/web/components/ui/alert-dialog.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-"use client";
-
-import * as React from "react";
-import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
-
-import { cn } from "~/lib/utils";
-import { buttonVariants } from "~/components/ui/button";
-
-function AlertDialog({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function AlertDialogTrigger({
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function AlertDialogPortal({ ...props }: React.ComponentProps) {
- return ;
-}
-
-function AlertDialogOverlay({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
-
-
-
- );
-}
-
-function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDialogTitle({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogDescription({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-function AlertDialogAction({
- className,
- ...props
-}: React.ComponentProps) {
- return ;
-}
-
-function AlertDialogCancel({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export {
- AlertDialog,
- AlertDialogPortal,
- AlertDialogOverlay,
- AlertDialogTrigger,
- AlertDialogContent,
- AlertDialogHeader,
- AlertDialogFooter,
- AlertDialogTitle,
- AlertDialogDescription,
- AlertDialogAction,
- AlertDialogCancel,
-};
diff --git a/apps/web/components/ui/alert.tsx b/apps/web/components/ui/alert.tsx
deleted file mode 100644
index c639e60..0000000
--- a/apps/web/components/ui/alert.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as React from "react";
-import { cva, type VariantProps } from "class-variance-authority";
-
-import { cn } from "~/lib/utils";
-
-const alertVariants = cva(
- "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
- {
- variants: {
- variant: {
- default: "bg-card text-card-foreground",
- destructive:
- "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-function Alert({
- className,
- variant,
- ...props
-}: React.ComponentProps<"div"> & VariantProps) {
- return (
-
- );
-}
-
-function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function AlertDescription({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export { Alert, AlertTitle, AlertDescription };
diff --git a/apps/web/components/ui/aspect-ratio.tsx b/apps/web/components/ui/aspect-ratio.tsx
deleted file mode 100644
index 59d6b35..0000000
--- a/apps/web/components/ui/aspect-ratio.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-"use client";
-
-import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
-
-function AspectRatio({ ...props }: React.ComponentProps) {
- return ;
-}
-
-export { AspectRatio };
diff --git a/apps/web/components/ui/avatar.tsx b/apps/web/components/ui/avatar.tsx
deleted file mode 100644
index 33de341..0000000
--- a/apps/web/components/ui/avatar.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-"use client";
-
-import * as React from "react";
-import * as AvatarPrimitive from "@radix-ui/react-avatar";
-
-import { cn } from "~/lib/utils";
-
-function Avatar({ className, ...props }: React.ComponentProps) {
- return (
-
- );
-}
-
-function AvatarImage({ className, ...props }: React.ComponentProps) {
- return (
-
- );
-}
-
-function AvatarFallback({
- className,
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export { Avatar, AvatarImage, AvatarFallback };
diff --git a/apps/web/components/ui/badge.tsx b/apps/web/components/ui/badge.tsx
deleted file mode 100644
index f9fee49..0000000
--- a/apps/web/components/ui/badge.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-
-import { cn } from "~/lib/utils";
-
-const badgeVariants = cva(
- "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
- {
- variants: {
- variant: {
- default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
- secondary:
- "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
- destructive:
- "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20",
- outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
- },
- },
- defaultVariants: {
- variant: "default",
- },
- },
-);
-
-function Badge({
- className,
- variant,
- asChild = false,
- ...props
-}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span";
-
- return (
-
- );
-}
-
-export { Badge, badgeVariants };
diff --git a/apps/web/components/ui/breadcrumb.tsx b/apps/web/components/ui/breadcrumb.tsx
deleted file mode 100644
index 1c75db0..0000000
--- a/apps/web/components/ui/breadcrumb.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import * as React from "react";
-import { Slot } from "@radix-ui/react-slot";
-import { ChevronRight, MoreHorizontal } from "lucide-react";
-
-import { cn } from "~/lib/utils";
-
-function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
- return ;
-}
-
-function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
- return (
-
- );
-}
-
-function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
- return (
-
- );
-}
-
-function BreadcrumbLink({
- asChild,
- className,
- ...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean;
-}) {
- const Comp = asChild ? Slot : "a";
-
- return (
-
- );
-}
-
-function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
-function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
- return (
- svg]:size-3.5", className)}
- {...props}
- >
- {children ?? }
-
- );
-}
-
-function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
-
- More
-
- );
-}
-
-export {
- Breadcrumb,
- BreadcrumbList,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbPage,
- BreadcrumbSeparator,
- BreadcrumbEllipsis,
-};
diff --git a/apps/web/components/ui/button-group.tsx b/apps/web/components/ui/button-group.tsx
deleted file mode 100644
index c0969bd..0000000
--- a/apps/web/components/ui/button-group.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-
-import { cn } from "~/lib/utils";
-import { Separator } from "~/components/ui/separator";
-
-const buttonGroupVariants = cva(
- "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
- {
- variants: {
- orientation: {
- horizontal:
- "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
- vertical:
- "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
- },
- },
- defaultVariants: {
- orientation: "horizontal",
- },
- },
-);
-
-function ButtonGroup({
- className,
- orientation,
- ...props
-}: React.ComponentProps<"div"> & VariantProps) {
- return (
-
- );
-}
-
-function ButtonGroupText({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<"div"> & {
- asChild?: boolean;
-}) {
- const Comp = asChild ? Slot : "div";
-
- return (
-
- );
-}
-
-function ButtonGroupSeparator({
- className,
- orientation = "vertical",
- ...props
-}: React.ComponentProps) {
- return (
-
- );
-}
-
-export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
diff --git a/apps/web/components/ui/calendar.tsx b/apps/web/components/ui/calendar.tsx
deleted file mode 100644
index c56971a..0000000
--- a/apps/web/components/ui/calendar.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
-import { DayPicker, getDefaultClassNames, type DayButton } from "react-day-picker";
-
-import { cn } from "~/lib/utils";
-import { Button, buttonVariants } from "~/components/ui/button";
-
-function Calendar({
- className,
- classNames,
- showOutsideDays = true,
- captionLayout = "label",
- buttonVariant = "ghost",
- formatters,
- components,
- ...props
-}: React.ComponentProps & {
- buttonVariant?: React.ComponentProps["variant"];
-}) {
- const defaultClassNames = getDefaultClassNames();
-
- return (
- svg]:rotate-180`,
- String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
- className,
- )}
- captionLayout={captionLayout}
- formatters={{
- formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
- ...formatters,
- }}
- classNames={{
- root: cn("w-fit", defaultClassNames.root),
- months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
- month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
- nav: cn(
- "flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
- defaultClassNames.nav,
- ),
- button_previous: cn(
- buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
- defaultClassNames.button_previous,
- ),
- button_next: cn(
- buttonVariants({ variant: buttonVariant }),
- "size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
- defaultClassNames.button_next,
- ),
- month_caption: cn(
- "flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
- defaultClassNames.month_caption,
- ),
- dropdowns: cn(
- "w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
- defaultClassNames.dropdowns,
- ),
- dropdown_root: cn(
- "relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
- defaultClassNames.dropdown_root,
- ),
- dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
- caption_label: cn(
- "select-none font-medium",
- captionLayout === "label"
- ? "text-sm"
- : "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
- defaultClassNames.caption_label,
- ),
- table: "w-full border-collapse",
- weekdays: cn("flex", defaultClassNames.weekdays),
- weekday: cn(
- "text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
- defaultClassNames.weekday,
- ),
- week: cn("flex w-full mt-2", defaultClassNames.week),
- week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
- week_number: cn(
- "text-[0.8rem] select-none text-muted-foreground",
- defaultClassNames.week_number,
- ),
- day: cn(
- "relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
- props.showWeekNumber
- ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
- : "[&:first-child[data-selected=true]_button]:rounded-l-md",
- defaultClassNames.day,
- ),
- range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
- range_middle: cn("rounded-none", defaultClassNames.range_middle),
- range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
- today: cn(
- "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
- defaultClassNames.today,
- ),
- outside: cn(
- "text-muted-foreground aria-selected:text-muted-foreground",
- defaultClassNames.outside,
- ),
- disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
- hidden: cn("invisible", defaultClassNames.hidden),
- ...classNames,
- }}
- components={{
- Root: ({ className, rootRef, ...props }) => {
- return
;
- },
- Chevron: ({ className, orientation, ...props }) => {
- if (orientation === "left") {
- return ;
- }
-
- if (orientation === "right") {
- return ;
- }
-
- return ;
- },
- DayButton: CalendarDayButton,
- WeekNumber: ({ children, ...props }) => {
- return (
-
-
- {children}
-
-
- );
- },
- ...components,
- }}
- {...props}
- />
- );
-}
-
-function CalendarDayButton({
- className,
- day,
- modifiers,
- ...props
-}: React.ComponentProps) {
- const defaultClassNames = getDefaultClassNames();
-
- const ref = React.useRef(null);
- React.useEffect(() => {
- if (modifiers.focused) ref.current?.focus();
- }, [modifiers.focused]);
-
- return (
- span]:text-xs [&>span]:opacity-70",
- defaultClassNames.day,
- className,
- )}
- {...props}
- />
- );
-}
-
-export { Calendar, CalendarDayButton };
diff --git a/apps/web/components/ui/card.tsx b/apps/web/components/ui/card.tsx
deleted file mode 100644
index 86efca5..0000000
--- a/apps/web/components/ui/card.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import * as React from "react";
-
-import { cn } from "~/lib/utils";
-
-function Card({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardAction({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-function CardContent({ className, ...props }: React.ComponentProps<"div">) {
- return
;
-}
-
-function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
diff --git a/apps/web/components/ui/carousel.tsx b/apps/web/components/ui/carousel.tsx
deleted file mode 100644
index cb6a7d2..0000000
--- a/apps/web/components/ui/carousel.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-"use client";
-
-import * as React from "react";
-import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
-import { ArrowLeft, ArrowRight } from "lucide-react";
-
-import { cn } from "~/lib/utils";
-import { Button } from "~/components/ui/button";
-
-type CarouselApi = UseEmblaCarouselType[1];
-type UseCarouselParameters = Parameters;
-type CarouselOptions = UseCarouselParameters[0];
-type CarouselPlugin = UseCarouselParameters[1];
-
-type CarouselProps = {
- opts?: CarouselOptions;
- plugins?: CarouselPlugin;
- orientation?: "horizontal" | "vertical";
- setApi?: (api: CarouselApi) => void;
-};
-
-type CarouselContextProps = {
- carouselRef: ReturnType[0];
- api: ReturnType[1];
- scrollPrev: () => void;
- scrollNext: () => void;
- canScrollPrev: boolean;
- canScrollNext: boolean;
-} & CarouselProps;
-
-const CarouselContext = React.createContext(null);
-
-function useCarousel() {
- const context = React.useContext(CarouselContext);
-
- if (!context) {
- throw new Error("useCarousel must be used within a ");
- }
-
- return context;
-}
-
-function Carousel({
- orientation = "horizontal",
- opts,
- setApi,
- plugins,
- className,
- children,
- ...props
-}: React.ComponentProps<"div"> & CarouselProps) {
- const [carouselRef, api] = useEmblaCarousel(
- {
- ...opts,
- axis: orientation === "horizontal" ? "x" : "y",
- },
- plugins,
- );
- const [canScrollPrev, setCanScrollPrev] = React.useState(false);
- const [canScrollNext, setCanScrollNext] = React.useState(false);
-
- const onSelect = React.useCallback((api: CarouselApi) => {
- if (!api) return;
- setCanScrollPrev(api.canScrollPrev());
- setCanScrollNext(api.canScrollNext());
- }, []);
-
- const scrollPrev = React.useCallback(() => {
- api?.scrollPrev();
- }, [api]);
-
- const scrollNext = React.useCallback(() => {
- api?.scrollNext();
- }, [api]);
-
- const handleKeyDown = React.useCallback(
- (event: React.KeyboardEvent) => {
- if (event.key === "ArrowLeft") {
- event.preventDefault();
- scrollPrev();
- } else if (event.key === "ArrowRight") {
- event.preventDefault();
- scrollNext();
- }
- },
- [scrollPrev, scrollNext],
- );
-
- React.useEffect(() => {
- if (!api || !setApi) return;
- setApi(api);
- }, [api, setApi]);
-
- React.useEffect(() => {
- if (!api) return;
- onSelect(api);
- api.on("reInit", onSelect);
- api.on("select", onSelect);
-
- return () => {
- api?.off("select", onSelect);
- };
- }, [api, onSelect]);
-
- return (
-
-
- {children}
-
-
- );
-}
-
-function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
- const { carouselRef, orientation } = useCarousel();
-
- return (
-
- );
-}
-
-function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
- const { orientation } = useCarousel();
-
- return (
-
- );
-}
-
-function CarouselPrevious({
- className,
- variant = "outline",
- size = "icon",
- ...props
-}: React.ComponentProps) {
- const { orientation, scrollPrev, canScrollPrev } = useCarousel();
-
- return (
-
-
- Previous slide
-
- );
-}
-
-function CarouselNext({
- className,
- variant = "outline",
- size = "icon",
- ...props
-}: React.ComponentProps) {
- const { orientation, scrollNext, canScrollNext } = useCarousel();
-
- return (
-
-
- Next slide
-
- );
-}
-
-export {
- type CarouselApi,
- Carousel,
- CarouselContent,
- CarouselItem,
- CarouselPrevious,
- CarouselNext,
-};
diff --git a/apps/web/components/ui/chart.tsx b/apps/web/components/ui/chart.tsx
deleted file mode 100644
index 6fe13a6..0000000
--- a/apps/web/components/ui/chart.tsx
+++ /dev/null
@@ -1,326 +0,0 @@
-"use client";
-
-import * as React from "react";
-import * as RechartsPrimitive from "recharts";
-
-import { cn } from "~/lib/utils";
-
-// Format: { THEME_NAME: CSS_SELECTOR }
-const THEMES = { light: "" } as const;
-
-export type ChartConfig = {
- [k in string]: {
- label?: React.ReactNode;
- icon?: React.ComponentType;
- } & (
- | { color?: string; theme?: never }
- | { color?: never; theme: Record }
- );
-};
-
-type ChartContextProps = {
- config: ChartConfig;
-};
-
-const ChartContext = React.createContext(null);
-
-function useChart() {
- const context = React.useContext(ChartContext);
-
- if (!context) {
- throw new Error("useChart must be used within a ");
- }
-
- return context;
-}
-
-function ChartContainer({
- id,
- className,
- children,
- config,
- ...props
-}: React.ComponentProps<"div"> & {
- config: ChartConfig;
- children: React.ComponentProps["children"];
-}) {
- const uniqueId = React.useId();
- const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
-
- return (
-
-
-
- {children}
-
-
- );
-}
-
-const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
- const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
-
- if (!colorConfig.length) {
- return null;
- }
-
- return (
-
-
-
-
- {!submitted && (
-
-
+
+
+ {!submitted && (
+
+
+ {form.title}
+
+ {form.description && (
+
+ {form.description}
+
+ )}
+
+ )}
+
+ {submitted ? (
+
+ ) : totalQuestions === 0 ? (
+
- {form.title}
-
- {form.description && (
-
- {form.description}
+
+ Empty form
+
+
Nothing to fill out yet
+
+ The author hasn't added any questions to this form.
- )}
-
- )}
-
- {submitted ? (
-
- ) : totalQuestions === 0 ? (
-
-
- Empty form
-
-
Nothing to fill out yet
-
- The author hasn't added any questions to this form.
-
-
- ) : (
-
- )}
-
-
-
+
+ ) : (
+
+ {canSaveDraft && (
+
+ )}
+
+ 1 ? "Next section" : "Next"}
+ />
+
+ )}
+
+
+
+
);
}
diff --git a/apps/web/app/learn-more/page.tsx b/apps/web/app/learn-more/page.tsx
index a8d7544..83aa5a4 100644
--- a/apps/web/app/learn-more/page.tsx
+++ b/apps/web/app/learn-more/page.tsx
@@ -178,7 +178,11 @@ export default function LearnMorePage() {
CanvasFlow is a form builder with the reading half taken as seriously as the writing
half. Twelve field types, one question at a time for whoever answers, and real numbers
@@ -275,9 +279,18 @@ export default function LearnMorePage() {
{[
["Durable data keys", "Rename a question and its answers stay attached."],
- ["Optimistic locking", "Two people editing at once get a conflict, not a silent overwrite."],
- ["One response per visitor", "A refresh or a double-click won't skew your numbers."],
- ["Ordered by design", "Fields carry a real index, so reordering never scrambles a form."],
+ [
+ "Optimistic locking",
+ "Two people editing at once get a conflict, not a silent overwrite.",
+ ],
+ [
+ "One response per visitor",
+ "A refresh or a double-click won't skew your numbers.",
+ ],
+ [
+ "Ordered by design",
+ "Fields carry a real index, so reordering never scrambles a form.",
+ ],
].map(([term, gloss]) => (
- The Free plan is ₹0, forever — ten active forms and a thousand submissions a
- month, with CSV export included.
+ Free and unlimited — as many forms and responses as you need, every field type, with
+ CSV export included.
Build for free →
-
- Compare plans
-
diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx
index c059513..ee7cbf3 100644
--- a/apps/web/app/privacy/page.tsx
+++ b/apps/web/app/privacy/page.tsx
@@ -60,10 +60,6 @@ const ACCOUNT_DATA = [
detail:
"Either a password — stored hashed, never in readable form — or the tokens your OAuth provider issues, plus which provider you used.",
},
- {
- what: "Plan",
- detail: "Your subscription tier, which determines submission and feature limits.",
- },
{
what: "Sessions",
detail:
@@ -76,8 +72,7 @@ const ACCOUNT_DATA = [
},
{
what: "Collaborators",
- detail:
- "If you share a form, we record who has access, their role, and who added them.",
+ detail: "If you share a form, we record who has access, their role, and who added them.",
},
{
what: "Support reports",
@@ -157,8 +152,7 @@ const RIGHTS = [
},
{
what: "Correct or delete",
- detail:
- "Edit or delete forms and responses directly. Deleting your account removes the rest.",
+ detail: "Edit or delete forms and responses directly. Deleting your account removes the rest.",
},
{
what: "If you answered someone's form",
@@ -278,11 +272,10 @@ export default function PrivacyPage() {
-
The form’s owner
-
+
+ The form’s owner
+
+
They decide what to ask, why, and what to do with the answers. For the responses
collected through their form, they are the party responsible — the controller, in
data-protection terms. What they ask for is their call, not ours.
@@ -290,13 +283,10 @@ export default function PrivacyPage() {
CanvasFlow
-
+
We store and process those responses on the owner’s behalf, and nothing more.
- For account data — your email, your sessions, your plan — we are the responsible
- party ourselves.
+ For account data — your email and your sessions — we are the responsible party
+ ourselves.
@@ -318,7 +308,7 @@ export default function PrivacyPage() {
className="mt-5 max-w-2xl text-[15px] leading-relaxed"
style={{ color: "var(--hex-ink-soft)" }}
>
- All of this exists to sign you in, keep your work, and enforce the limits of your plan.
+ All of this exists to sign you in and keep your work. Nothing more.
@@ -533,8 +523,8 @@ export default function PrivacyPage() {
style={{ color: "var(--hex-ink-soft)" }}
>
Depending on where you live you may have further rights over your personal data,
- including the right to complain to a supervisory authority. Exercising any of them
- costs you nothing and we won’t degrade your account for asking.
+ including the right to complain to a supervisory authority. Exercising any of them costs
+ you nothing and we won’t degrade your account for asking.
@@ -551,11 +541,10 @@ export default function PrivacyPage() {
-
How it’s protected
-
+
+ How it’s protected
+
+
Traffic is encrypted in transit. Passwords are hashed, never stored readably. Access
to a form’s responses is checked on every request against ownership or an
explicit collaborator role. No system is perfect, and we won’t pretend
@@ -565,10 +554,7 @@ export default function PrivacyPage() {
Children
-
+
CanvasFlow isn’t intended for children, and we don’t knowingly create
accounts for them. If you believe a child’s personal data has reached us
through an account or a form, tell us and we will remove it.
diff --git a/apps/web/app/security/page.tsx b/apps/web/app/security/page.tsx
index d470e13..f8c645b 100644
--- a/apps/web/app/security/page.tsx
+++ b/apps/web/app/security/page.tsx
@@ -59,11 +59,6 @@ const AUTHZ_LAYERS = [
detail:
"Every authenticated endpoint revalidates the session server-side before it runs. There is no trusted client state — a request with a stale or forged cookie is rejected at the boundary, not deeper in.",
},
- {
- what: "The plan, read from the database",
- detail:
- "Plan-gated features re-read your tier from the database on each call rather than believing whatever the browser claims. Editing local storage to say “Business” gets you a 403, not detailed analytics.",
- },
{
what: "The role, per form",
detail:
@@ -122,11 +117,6 @@ const LIMITS = [
detail:
"Request bodies are capped at 200 KB. A form submission is small; anything far larger is either a mistake or an attempt to exhaust memory.",
},
- {
- what: "Monthly submission caps",
- detail:
- "Each plan has a monthly submission ceiling counted across all your forms. Beyond billing, it bounds how much traffic a single account can generate if one of its forms is targeted.",
- },
{
what: "Report flooding",
detail:
@@ -495,20 +485,19 @@ export default function SecurityPage() {
We’d genuinely rather hear it from you than find out the hard way. Report what
you found, how to reproduce it, and what you think the impact is. We’ll confirm
- we received it, keep you posted while we fix it, and credit you if you’d like
- to be credited.
+ we received it, keep you posted while we fix it, and credit you if you’d like to
+ be credited.
Please test only against your own account and your own forms, don’t run
automated scans or load tests against the service, and don’t access, alter, or
retain anyone else’s responses. Report it and stop there — that’s enough
- to prove the point, and we won’t pursue researchers who stay within those
- lines.
+ to prove the point, and we won’t pursue researchers who stay within those lines.
{!SECURITY_EMAIL && (
- Use the feedback option inside the app to reach us — it routes straight to us.
- Mark it as a bug and mention it’s security-related so we prioritise it.
+ Use the feedback option inside the app to reach us — it routes straight to us. Mark
+ it as a bug and mention it’s security-related so we prioritise it.
)}
diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx
index b579bf1..9189572 100644
--- a/apps/web/app/terms/page.tsx
+++ b/apps/web/app/terms/page.tsx
@@ -9,7 +9,7 @@ import { HorizontalScale, VerticalScale } from "~/components/Scale";
export const metadata: Metadata = {
title: "Terms of Service · CanvasFlow",
description:
- "The agreement for using CanvasFlow: what you may build, the limits that apply to your plan, who owns your content, and what you owe the people who answer your forms.",
+ "The agreement for using CanvasFlow: what you may build, who owns your content, and what you owe the people who answer your forms.",
};
/* ============================================================
@@ -22,21 +22,18 @@ export const metadata: Metadata = {
· LEGAL_ENTITY — the contracting party. Until it is set,
nothing here names a company, which means there is no
identified counterparty to the agreement.
- · A governing-law and dispute-resolution clause. Prices on
- /dashboard/pricing are in INR, which implies India, but
- that is an inference and not something to assert here.
+ · A governing-law and dispute-resolution clause.
· Consumer-law carve-outs for your markets. The liability
and disclaimer sections below are drafted broadly and are
unenforceable as written in several jurisdictions.
Kept deliberately accurate to the build as of LAST_UPDATED:
- · The plan limits in PLANS are the values actually enforced
- in packages/services/form-submission (submitForm).
- · No payment processor is integrated, so there are no
- billing, renewal, refund, or tax terms here. Add them
- before paid plans become purchasable — see "Paid plans".
- If you change an enforced limit, change PLANS in the same
- commit or this page becomes a misrepresentation.
+ · The service is free and metered by nothing. There are no
+ plans, no submission or form ceilings, and no payment
+ processor — so there are no billing, renewal, refund, or
+ tax terms here. Add all of the above together if paid
+ tiers are ever introduced; a pricing claim without an
+ enforced limit behind it is a misrepresentation.
============================================================ */
const CONTACT_EMAIL: string | null = null;
const LEGAL_ENTITY: string | null = null;
@@ -46,15 +43,7 @@ const SUMMARY = [
"Your forms and responses are yours. We host them, we don't claim them, and we don't use them to advertise.",
"You're responsible for what you ask people and what you do with their answers.",
"Don't use CanvasFlow to phish, impersonate, or collect data you have no right to collect.",
- "Each plan has a monthly submission limit, counted across every form you own.",
-];
-
-/** Limits as actually enforced on submit, not as marketed. */
-const PLANS = [
- { name: "Free", limit: "1,000 submissions per month" },
- { name: "Pro", limit: "10,000 submissions per month" },
- { name: "Pro+", limit: "50,000 submissions per month" },
- { name: "Business", limit: "500,000 submissions per month" },
+ "The service is free to use, with no cap on how many forms you build or responses you collect.",
];
const ACCOUNT_TERMS = [
@@ -171,9 +160,9 @@ const SERVICE_TERMS = [
"Features change. We may add, alter, or retire them. If we remove something you rely on, or reduce a limit, we'll give you notice and time to export your data first.",
},
{
- what: "Your own limits",
+ what: "Your own controls",
detail:
- "Independently of your plan, you can close a form, set an expiry date, or cap its submissions. Those are your controls and we enforce them as set.",
+ "You can close a form to new responses or give it an expiry date. Those are your controls and we enforce them as set.",
},
{
what: "Suspension",
@@ -319,7 +308,7 @@ export default function TermsPage() {
- {/* ── Plans and limits ───────────────────────────────────────── */}
+ {/* ── Cost ───────────────────────────────────────────────────── */}
- Plans and limits
+ What it costs
-
- Every plan gets the whole builder, the analytics, and unlimited respondents. What scales
- is how many submissions you can receive in a month.
-
-
- {PLANS.map((plan) => (
-
-
- {plan.name}
-
-
- {plan.limit}
-
-
- ))}
-
-
-
+
- The count covers every form you own, added together, and resets at the start of each
- calendar month. Reaching it stops new submissions on your forms until the reset or an
- upgrade — your existing responses are never touched.
+ Nothing. There are no plans or tiers, no card is required, and there is no cap on how
+ many forms you build or how many responses you collect. Every account gets the whole
+ builder, the analytics, and unlimited respondents.
- A submission counts against the owner of the form. If a form is transferred, later
- submissions count against whoever owns it then.
+ No payment processor is integrated, so nothing in these terms authorises us to charge
+ you. If that ever changes, the billing terms — price, renewal, cancellation, refunds,
+ and tax — will be presented and accepted before any charge.
@@ -374,34 +338,6 @@ export default function TermsPage() {
- {/* ── Paid plans ─────────────────────────────────────────────── */}
-
-
-
- Paid plans
-
-
-
-
- Paid tiers are listed on the{" "}
-
- pricing page
- {" "}
- but aren’t purchasable yet, and the Free tier requires no card. Nothing in these
- terms authorises us to charge you today.
-
-
- When paid plans open, the billing terms — price, renewal, cancellation, refunds, and
- tax — will be presented and accepted at that point, before any charge. Prices shown
- now are indicative and may change before launch.
-
-
-
-
-
{/* ── Your content ───────────────────────────────────────────── */}
-
- Provided as it is
-
-
+
Provided as it is
+
CanvasFlow is provided without warranties beyond those the law won’t let us
exclude. We don’t promise it will be uninterrupted, error-free, or fit for a
particular purpose you have in mind. Keep your own copies of anything you
@@ -610,10 +541,7 @@ export default function TermsPage() {
Liability
-
+
To the extent the law allows, we aren’t liable for indirect or consequential
loss, lost profits, or lost data, and our total liability is limited to what you
paid us in the twelve months before the claim. Nothing here limits liability for
diff --git a/apps/web/components/BlendIcon.tsx b/apps/web/components/BlendIcon.tsx
index 3340bfd..f7abf3f 100644
--- a/apps/web/components/BlendIcon.tsx
+++ b/apps/web/components/BlendIcon.tsx
@@ -6,154 +6,145 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface BlendHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface BlendProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const BlendIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e?: React.MouseEvent) => {
- if (!isControlled.current) controls.start("normal");
- else onMouseLeave?.(e as any);
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
- const groupVariants: Variants = {
- normal: { rotate: 0, scale: 1 },
- animate: {
- rotate: [0, -1.5, 1.5, 0],
- scale: [1, 1.02, 1],
- transition: {
- duration: 0.7 * duration,
- ease: "easeInOut" as const,
- },
- },
- };
+ const groupVariants: Variants = {
+ normal: { rotate: 0, scale: 1 },
+ animate: {
+ rotate: [0, -1.5, 1.5, 0],
+ scale: [1, 1.02, 1],
+ transition: {
+ duration: 0.7 * duration,
+ ease: "easeInOut" as const,
+ },
+ },
+ };
- const leftCircle: Variants = {
- normal: { strokeDashoffset: 0, opacity: 1, x: 0 },
- animate: {
- strokeDashoffset: [48, 0],
- opacity: [0.45, 1],
- transition: { duration: 0.6 * duration, ease: "easeInOut" },
- },
- };
+ const leftCircle: Variants = {
+ normal: { strokeDashoffset: 0, opacity: 1, x: 0 },
+ animate: {
+ strokeDashoffset: [48, 0],
+ opacity: [0.45, 1],
+ transition: { duration: 0.6 * duration, ease: "easeInOut" },
+ },
+ };
- const rightCircle: Variants = {
- normal: { strokeDashoffset: 0, opacity: 1, x: 0 },
- animate: {
- strokeDashoffset: [48, 0],
- opacity: [0.45, 1],
- transition: {
- duration: 0.6 * duration,
- ease: "easeInOut",
- delay: 0.09,
- },
- },
- };
+ const rightCircle: Variants = {
+ normal: { strokeDashoffset: 0, opacity: 1, x: 0 },
+ animate: {
+ strokeDashoffset: [48, 0],
+ opacity: [0.45, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ delay: 0.09,
+ },
+ },
+ };
- const overlapPulse: Variants = {
- normal: { scale: 1, opacity: 1 },
- animate: {
- scale: [1, 1.06, 1],
- opacity: [0.9, 1, 1],
- transition: {
- duration: 0.5 * duration,
- ease: "easeInOut",
- delay: 0.25,
- },
- },
- };
+ const overlapPulse: Variants = {
+ normal: { scale: 1, opacity: 1 },
+ animate: {
+ scale: [1, 1.06, 1],
+ opacity: [0.9, 1, 1],
+ transition: {
+ duration: 0.5 * duration,
+ ease: "easeInOut",
+ delay: 0.25,
+ },
+ },
+ };
- return (
-
-
-
-
-
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
);
BlendIcon.displayName = "BlendIcon";
-export { BlendIcon };
\ No newline at end of file
+export { BlendIcon };
diff --git a/apps/web/components/ChartBarIcon.tsx b/apps/web/components/ChartBarIcon.tsx
index ab6f02c..f5456a3 100644
--- a/apps/web/components/ChartBarIcon.tsx
+++ b/apps/web/components/ChartBarIcon.tsx
@@ -6,122 +6,113 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface ChartBarIconHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface ChartBarIconProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const ChartBarIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e: React.MouseEvent) => {
- if (!isControlled.current) {
- controls.start("normal");
- } else {
- onMouseLeave?.(e as any);
- }
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isControlled.current) {
+ controls.start("normal");
+ } else {
+ onMouseLeave?.(e as any);
+ }
+ },
+ [controls, onMouseLeave],
+ );
- const pathVariants: Variants = {
- normal: {
- pathLength: 1,
- opacity: 1,
- transition: { duration: 0.2 * duration },
- },
- animate: {
- pathLength: [0, 1],
- opacity: [0.7, 1],
- transition: {
- duration: 0.6 * duration,
- ease: "easeInOut",
- },
- },
- };
+ const pathVariants: Variants = {
+ normal: {
+ pathLength: 1,
+ opacity: 1,
+ transition: { duration: 0.2 * duration },
+ },
+ animate: {
+ pathLength: [0, 1],
+ opacity: [0.7, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
- const chartVariants: Variants = {
- normal: {
- scale: 1,
- transition: { duration: 0.2 * duration },
- },
- animate: {
- scale: [1, 1.05, 1],
- transition: {
- duration: 0.6 * duration,
- ease: "easeInOut",
- },
- },
- };
+ const chartVariants: Variants = {
+ normal: {
+ scale: 1,
+ transition: { duration: 0.2 * duration },
+ },
+ animate: {
+ scale: [1, 1.05, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
- return (
-
-
-
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+
+
+
+ );
+ },
);
ChartBarIcon.displayName = "ChartBarIcon";
-export { ChartBarIcon };
\ No newline at end of file
+export { ChartBarIcon };
diff --git a/apps/web/components/CheckIcon.tsx b/apps/web/components/CheckIcon.tsx
index 3ed6139..630577f 100644
--- a/apps/web/components/CheckIcon.tsx
+++ b/apps/web/components/CheckIcon.tsx
@@ -6,117 +6,108 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface DoubleCheckHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface CheckCheckIconProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const CheckCheckIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e?: React.MouseEvent) => {
- if (!isControlled.current) controls.start("normal");
- else onMouseLeave?.(e as any);
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
- const tick1Variants: Variants = {
- normal: { strokeDashoffset: 0, scale: 1, opacity: 1 },
- animate: {
- strokeDashoffset: [20, 0],
- scale: [1, 1.2, 1],
- opacity: [0.5, 1],
- transition: { duration: 0.7 * duration, ease: "easeInOut" },
- },
- };
+ const tick1Variants: Variants = {
+ normal: { strokeDashoffset: 0, scale: 1, opacity: 1 },
+ animate: {
+ strokeDashoffset: [20, 0],
+ scale: [1, 1.2, 1],
+ opacity: [0.5, 1],
+ transition: { duration: 0.7 * duration, ease: "easeInOut" },
+ },
+ };
- const tick2Variants: Variants = {
- normal: { opacity: 1, x: 0 },
- animate: {
- opacity: [0, 1],
- x: [-6, 0],
- transition: { duration: 0.5 * duration, ease: "easeOut", delay: 0.35 },
- },
- };
+ const tick2Variants: Variants = {
+ normal: { opacity: 1, x: 0 },
+ animate: {
+ opacity: [0, 1],
+ x: [-6, 0],
+ transition: { duration: 0.5 * duration, ease: "easeOut", delay: 0.35 },
+ },
+ };
- return (
-
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+
+ );
+ },
);
CheckCheckIcon.displayName = "CheckCheckIcon";
-export { CheckCheckIcon };
\ No newline at end of file
+export { CheckCheckIcon };
diff --git a/apps/web/components/CopyIcon.tsx b/apps/web/components/CopyIcon.tsx
index 2a71a8a..5b1ed8d 100644
--- a/apps/web/components/CopyIcon.tsx
+++ b/apps/web/components/CopyIcon.tsx
@@ -6,122 +6,113 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface CopyIconHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface CopyIconProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const CopyIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e: React.MouseEvent) => {
- if (!isControlled.current) {
- controls.start("normal");
- } else {
- onMouseLeave?.(e as any);
- }
- },
- [controls, onMouseLeave],
- );
- const defaultTransition: Transition = {
- type: "spring",
- stiffness: 160,
- damping: 17,
- mass: 1,
- };
+ const handleLeave = useCallback(
+ (e: React.MouseEvent) => {
+ if (!isControlled.current) {
+ controls.start("normal");
+ } else {
+ onMouseLeave?.(e as any);
+ }
+ },
+ [controls, onMouseLeave],
+ );
+ const defaultTransition: Transition = {
+ type: "spring",
+ stiffness: 160,
+ damping: 17,
+ mass: 1,
+ };
- const boxVariants = {
- normal: { translateY: 0, translateX: 0, rotate: 0 },
- animate: { translateY: -3, translateX: -3, rotate: 360 },
- };
+ const boxVariants = {
+ normal: { translateY: 0, translateX: 0, rotate: 0 },
+ animate: { translateY: -3, translateX: -3, rotate: 360 },
+ };
- const pathVariants = {
- normal: { x: 0, y: 0 },
- animate: { x: 3, y: 3 },
- };
+ const pathVariants = {
+ normal: { x: 0, y: 0 },
+ animate: { x: 3, y: 3 },
+ };
- return (
-
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+
+ );
+ },
);
CopyIcon.displayName = "CopyIcon";
-export { CopyIcon };
\ No newline at end of file
+export { CopyIcon };
diff --git a/apps/web/components/Cubes.tsx b/apps/web/components/Cubes.tsx
index 430c001..ecd6c71 100644
--- a/apps/web/components/Cubes.tsx
+++ b/apps/web/components/Cubes.tsx
@@ -1,7 +1,7 @@
"use client";
-import React, { useCallback, useEffect, useRef } from 'react';
-import gsap from 'gsap';
+import React, { useCallback, useEffect, useRef } from "react";
+import gsap from "gsap";
interface Gap {
row: number;
@@ -34,16 +34,16 @@ const Cubes: React.FC = ({
cubeSize,
maxAngle = 45,
radius = 3,
- easing = 'power3.out',
+ easing = "power3.out",
duration = { enter: 0.3, leave: 0.6 },
cellGap,
- borderStyle = '1px solid #fff',
- faceColor = '#120F17',
+ borderStyle = "1px solid #fff",
+ faceColor = "#120F17",
shadow = false,
autoAnimate = true,
rippleOnClick = true,
- rippleColor = '#fff',
- rippleSpeed = 2
+ rippleColor = "#fff",
+ rippleSpeed = 2,
}) => {
const sceneRef = useRef(null);
const rafRef = useRef(null);
@@ -54,17 +54,17 @@ const Cubes: React.FC = ({
const simRAFRef = useRef(null);
const colGap =
- typeof cellGap === 'number'
+ typeof cellGap === "number"
? `${cellGap}px`
: (cellGap as Gap)?.col !== undefined
? `${(cellGap as Gap).col}px`
- : '5%';
+ : "5%";
const rowGap =
- typeof cellGap === 'number'
+ typeof cellGap === "number"
? `${cellGap}px`
: (cellGap as Gap)?.row !== undefined
? `${(cellGap as Gap).row}px`
- : '5%';
+ : "5%";
const enterDur = duration.enter;
const leaveDur = duration.leave;
@@ -72,7 +72,7 @@ const Cubes: React.FC = ({
const tiltAt = useCallback(
(rowCenter: number, colCenter: number) => {
if (!sceneRef.current) return;
- sceneRef.current.querySelectorAll('.cube').forEach(cube => {
+ sceneRef.current.querySelectorAll(".cube").forEach((cube) => {
const r = +cube.dataset.row!;
const c = +cube.dataset.col!;
const dist = Math.hypot(r - rowCenter, c - colCenter);
@@ -84,20 +84,20 @@ const Cubes: React.FC = ({
ease: easing,
overwrite: true,
rotateX: -angle,
- rotateY: angle
+ rotateY: angle,
});
} else {
gsap.to(cube, {
duration: leaveDur,
- ease: 'power3.out',
+ ease: "power3.out",
overwrite: true,
rotateX: 0,
- rotateY: 0
+ rotateY: 0,
});
}
});
},
- [radius, maxAngle, enterDur, leaveDur, easing]
+ [radius, maxAngle, enterDur, leaveDur, easing],
);
const onPointerMove = useCallback(
@@ -118,18 +118,18 @@ const Cubes: React.FC = ({
userActiveRef.current = false;
}, 3000);
},
- [gridSize, tiltAt]
+ [gridSize, tiltAt],
);
const resetAll = useCallback(() => {
if (!sceneRef.current) return;
- sceneRef.current.querySelectorAll('.cube').forEach(cube =>
+ sceneRef.current.querySelectorAll(".cube").forEach((cube) =>
gsap.to(cube, {
duration: leaveDur,
rotateX: 0,
rotateY: 0,
- ease: 'power3.out'
- })
+ ease: "power3.out",
+ }),
);
}, [leaveDur]);
@@ -155,7 +155,7 @@ const Cubes: React.FC = ({
userActiveRef.current = false;
}, 3000);
},
- [gridSize, tiltAt]
+ [gridSize, tiltAt],
);
const onTouchStart = useCallback(() => {
@@ -190,7 +190,7 @@ const Cubes: React.FC = ({
const holdTime = baseHold / rippleSpeed;
const rings: Record = {};
- sceneRef.current.querySelectorAll('.cube').forEach(cube => {
+ sceneRef.current.querySelectorAll(".cube").forEach((cube) => {
const r = +cube.dataset.row!;
const c = +cube.dataset.col!;
const dist = Math.hypot(r - rowHit, c - colHit);
@@ -202,38 +202,38 @@ const Cubes: React.FC = ({
Object.keys(rings)
.map(Number)
.sort((a, b) => a - b)
- .forEach(ring => {
+ .forEach((ring) => {
const delay = ring * spreadDelay;
- const faces = (rings[ring] ?? []).flatMap(cube =>
- Array.from(cube.querySelectorAll('.cube-face'))
+ const faces = (rings[ring] ?? []).flatMap((cube) =>
+ Array.from(cube.querySelectorAll(".cube-face")),
);
gsap.to(faces, {
backgroundColor: rippleColor,
duration: animDuration,
delay,
- ease: 'power3.out'
+ ease: "power3.out",
});
gsap.to(faces, {
backgroundColor: faceColor,
duration: animDuration,
delay: delay + animDuration + holdTime,
- ease: 'power3.out'
+ ease: "power3.out",
});
});
},
- [rippleOnClick, gridSize, faceColor, rippleColor, rippleSpeed]
+ [rippleOnClick, gridSize, faceColor, rippleColor, rippleSpeed],
);
useEffect(() => {
if (!autoAnimate || !sceneRef.current) return;
simPosRef.current = {
x: Math.random() * gridSize,
- y: Math.random() * gridSize
+ y: Math.random() * gridSize,
};
simTargetRef.current = {
x: Math.random() * gridSize,
- y: Math.random() * gridSize
+ y: Math.random() * gridSize,
};
const speed = 0.02;
const loop = () => {
@@ -246,7 +246,7 @@ const Cubes: React.FC = ({
if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) {
simTargetRef.current = {
x: Math.random() * gridSize,
- y: Math.random() * gridSize
+ y: Math.random() * gridSize,
};
}
}
@@ -261,22 +261,22 @@ const Cubes: React.FC = ({
useEffect(() => {
const el = sceneRef.current;
if (!el) return;
- el.addEventListener('pointermove', onPointerMove);
- el.addEventListener('pointerleave', resetAll);
- el.addEventListener('click', onClick);
+ el.addEventListener("pointermove", onPointerMove);
+ el.addEventListener("pointerleave", resetAll);
+ el.addEventListener("click", onClick);
- el.addEventListener('touchmove', onTouchMove, { passive: false });
- el.addEventListener('touchstart', onTouchStart, { passive: true });
- el.addEventListener('touchend', onTouchEnd, { passive: true });
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
+ el.addEventListener("touchend", onTouchEnd, { passive: true });
return () => {
- el.removeEventListener('pointermove', onPointerMove);
- el.removeEventListener('pointerleave', resetAll);
- el.removeEventListener('click', onClick);
+ el.removeEventListener("pointermove", onPointerMove);
+ el.removeEventListener("pointerleave", resetAll);
+ el.removeEventListener("click", onClick);
- el.removeEventListener('touchmove', onTouchMove);
- el.removeEventListener('touchstart', onTouchStart);
- el.removeEventListener('touchend', onTouchEnd);
+ el.removeEventListener("touchmove", onTouchMove);
+ el.removeEventListener("touchstart", onTouchStart);
+ el.removeEventListener("touchend", onTouchEnd);
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
@@ -285,23 +285,25 @@ const Cubes: React.FC = ({
const cells = Array.from({ length: gridSize });
const sceneStyle: React.CSSProperties = {
- gridTemplateColumns: cubeSize ? `repeat(${gridSize}, ${cubeSize}px)` : `repeat(${gridSize}, 1fr)`,
+ gridTemplateColumns: cubeSize
+ ? `repeat(${gridSize}, ${cubeSize}px)`
+ : `repeat(${gridSize}, 1fr)`,
gridTemplateRows: cubeSize ? `repeat(${gridSize}, ${cubeSize}px)` : `repeat(${gridSize}, 1fr)`,
columnGap: colGap,
rowGap: rowGap,
- perspective: '99999999px',
- gridAutoRows: '1fr'
+ perspective: "99999999px",
+ gridAutoRows: "1fr",
};
const wrapperStyle = {
- '--cube-face-border': borderStyle,
- '--cube-face-bg': faceColor,
- '--cube-face-shadow': shadow === true ? '0 0 6px rgba(0,0,0,.5)' : shadow || 'none',
+ "--cube-face-border": borderStyle,
+ "--cube-face-bg": faceColor,
+ "--cube-face-shadow": shadow === true ? "0 0 6px rgba(0,0,0,.5)" : shadow || "none",
...(cubeSize
? {
width: `${gridSize * cubeSize}px`,
- height: `${gridSize * cubeSize}px`
+ height: `${gridSize * cubeSize}px`,
}
- : {})
+ : {}),
} as React.CSSProperties;
return (
@@ -320,63 +322,63 @@ const Cubes: React.FC = ({
- ))
+ )),
)}
);
};
-export default Cubes;
\ No newline at end of file
+export default Cubes;
diff --git a/apps/web/components/DashboardNav.tsx b/apps/web/components/DashboardNav.tsx
index 65fe3e8..62e18a4 100644
--- a/apps/web/components/DashboardNav.tsx
+++ b/apps/web/components/DashboardNav.tsx
@@ -4,16 +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,
- Wallet,
- X,
-} from "lucide-react";
+import { BarChart3, ChevronRight, Compass, Menu, PencilRuler, Plus, X } from "lucide-react";
import { useDashboard } from "~/providers/dashboard-provider";
import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
@@ -33,7 +24,6 @@ const LINKS = [
{ href: "/dashboard", label: "Studio", icon: Compass },
{ href: "/dashboard/sketches", label: "Forms", icon: PencilRuler },
{ href: "/dashboard/analytics", label: "Analytics", icon: BarChart3 },
- { href: "/dashboard/pricing", label: "Pricing", icon: Wallet },
];
export default function DashboardNav() {
@@ -92,9 +82,7 @@ export default function DashboardNav() {
aria-current={active ? "page" : undefined}
className="cf-btn-outline h-8 gap-1.5 px-3 text-[10px] font-bold tracking-[0.16em] uppercase"
style={
- active
- ? { background: "var(--cf-ink)", color: "var(--cf-cream)" }
- : undefined
+ active ? { background: "var(--cf-ink)", color: "var(--cf-cream)" } : undefined
}
>
@@ -185,10 +173,7 @@ export default function DashboardNav() {
{/* Signing out lives on the profile page now, so this row is
purely the way there — full width, no trailing action. */}
-
+
setMenuOpen(false)}
@@ -199,10 +184,7 @@ export default function DashboardNav() {
diff --git a/apps/web/components/FeedbackWidget.tsx b/apps/web/components/FeedbackWidget.tsx
index ecd9924..7e5a1a5 100644
--- a/apps/web/components/FeedbackWidget.tsx
+++ b/apps/web/components/FeedbackWidget.tsx
@@ -1,37 +1,48 @@
"use client";
-import React, { FormEvent, useEffect, useRef, useState } from 'react';
-import { usePathname } from 'next/navigation';
-import { MessageSquareWarning, X, Send, Bug, MessageCircle, Lightbulb, AlertTriangle } from 'lucide-react';
-import { toast } from 'sonner';
-import { useGetLoggedInUserInfo } from '~/hooks/api/auth';
-import { useSubmitFeedback } from '~/hooks/api/feedback';
-import { cn } from '~/lib/utils';
-import { Button } from '~/components/ui/button';
-import { Input } from '~/components/ui/input';
-import { Textarea } from '~/components/ui/textarea';
+import React, { FormEvent, useEffect, useRef, useState } from "react";
+import { usePathname } from "next/navigation";
+import {
+ MessageSquareWarning,
+ X,
+ Send,
+ Bug,
+ MessageCircle,
+ Lightbulb,
+ AlertTriangle,
+} from "lucide-react";
+import { toast } from "sonner";
+import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
+import { useSubmitFeedback } from "~/hooks/api/feedback";
+import { cn } from "~/lib/utils";
+import { Button } from "~/components/ui/button";
+import { Input } from "~/components/ui/input";
+import { Textarea } from "~/components/ui/textarea";
-type ComplaintType = 'bug' | 'feedback' | 'complaint' | 'feature_request';
+type ComplaintType = "bug" | "feedback" | "complaint" | "feature_request";
-const typeConfig: Record
= {
+const typeConfig: Record<
+ ComplaintType,
+ { label: string; description: string; icon: React.ReactNode }
+> = {
feedback: {
- label: 'Feedback',
- description: 'Share product thoughts',
+ label: "Feedback",
+ description: "Share product thoughts",
icon: ,
},
bug: {
- label: 'Bug report',
- description: 'Something is broken',
+ label: "Bug report",
+ description: "Something is broken",
icon: ,
},
feature_request: {
- label: 'Feature request',
- description: 'Request an improvement',
+ label: "Feature request",
+ description: "Request an improvement",
icon: ,
},
complaint: {
- label: 'Complaint',
- description: 'Escalate an issue',
+ label: "Complaint",
+ description: "Escalate an issue",
icon: ,
},
};
@@ -39,9 +50,9 @@ const typeConfig: Record {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
- const [type, setType] = useState('feedback');
- const [subject, setSubject] = useState('');
- const [message, setMessage] = useState('');
+ const [type, setType] = useState("feedback");
+ const [subject, setSubject] = useState("");
+ const [message, setMessage] = useState("");
const subjectRef = useRef(null);
const { userInfo: user } = useGetLoggedInUserInfo();
const { submitFeedbackAsync, isPending: loading } = useSubmitFeedback();
@@ -51,13 +62,13 @@ const FeedbackWidget = () => {
const focusTimer = window.setTimeout(() => subjectRef.current?.focus(), 100);
const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') setIsOpen(false);
+ if (event.key === "Escape") setIsOpen(false);
};
- window.addEventListener('keydown', handleKeyDown);
+ window.addEventListener("keydown", handleKeyDown);
return () => {
window.clearTimeout(focusTimer);
- window.removeEventListener('keydown', handleKeyDown);
+ window.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
@@ -67,11 +78,11 @@ const FeedbackWidget = () => {
// Mirrors the server's minimums so the caller gets told before the round
// trip. The server still enforces them — this is convenience, not a check.
if (subject.trim().length < 3) {
- toast.error('Please add a subject (at least 3 characters)');
+ toast.error("Please add a subject (at least 3 characters)");
return;
}
if (message.trim().length < 10) {
- toast.error('Please add a little more detail (at least 10 characters)');
+ toast.error("Please add a little more detail (at least 10 characters)");
return;
}
@@ -88,18 +99,20 @@ const FeedbackWidget = () => {
type,
subject: subject.trim(),
message: message.trim(),
- pageUrl: typeof window !== 'undefined' ? window.location.href : null,
+ pageUrl: typeof window !== "undefined" ? window.location.href : null,
});
toast.success("Thanks — we've got it.");
- setSubject('');
- setMessage('');
- setType('feedback');
+ setSubject("");
+ setMessage("");
+ setType("feedback");
setIsOpen(false);
} catch (err) {
// Surface the server's message where there is one: it carries the real
// reason (too short, over the hourly cap) instead of a generic failure.
- toast.error(err instanceof Error && err.message ? err.message : 'Failed to submit. Please try again.');
+ toast.error(
+ err instanceof Error && err.message ? err.message : "Failed to submit. Please try again.",
+ );
}
};
@@ -109,7 +122,14 @@ const FeedbackWidget = () => {
// view of a form somebody else built — they aren't a CanvasFlow user, and a
// floating "report a bug in CanvasFlow" button there is both confusing and a
// distraction from the form they came to answer.
- if (pathname?.startsWith('/forms/')) return null;
+ if (pathname?.startsWith("/forms/")) return null;
+
+ // Hidden in the builder too. It's a full-bleed workspace whose own canvas
+ // controls (zoom, fit, lock) sit in the bottom-left and whose inspector runs
+ // to the bottom-right edge, so a floating button there covers the tool it
+ // overlaps. `/dashboard/sketches` (the list) keeps the widget; only the
+ // per-form builder loses it.
+ if (/^\/dashboard\/sketches\/[^/]+/.test(pathname ?? "")) return null;
return (
<>
@@ -120,7 +140,7 @@ const FeedbackWidget = () => {
size="icon"
className={cn(
"fixed bottom-5 right-5 z-50 h-12 w-12 border border-foreground/15 bg-foreground text-background shadow-[3px_3px_0_0_var(--foreground)] hover:bg-foreground/90 focus-visible:ring-accent md:bottom-6 md:right-6",
- isOpen && "hidden"
+ isOpen && "hidden",
)}
title="Report Issue / Feedback"
aria-label="Open report and feedback form"
@@ -137,7 +157,10 @@ const FeedbackWidget = () => {
>
-
@@ -163,30 +186,46 @@ const FeedbackWidget = () => {
Type
- {(Object.entries(typeConfig) as [ComplaintType, typeof typeConfig[ComplaintType]][]).map(([key, cfg]) => (
- setType(key)}
- aria-pressed={type === key}
- className={cn(
- "flex min-h-14.5 items-start gap-2 border border-foreground/15 bg-background p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2",
- type === key && "border-foreground bg-foreground text-background hover:bg-foreground/90"
- )}
- >
-
- {cfg.label}
-
- {cfg.description}
+ {(
+ Object.entries(typeConfig) as [
+ ComplaintType,
+ (typeof typeConfig)[ComplaintType],
+ ][]
+ ).map(([key, cfg]) => (
+ setType(key)}
+ aria-pressed={type === key}
+ className={cn(
+ "flex min-h-14.5 items-start gap-2 border border-foreground/15 bg-background p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2",
+ type === key &&
+ "border-foreground bg-foreground text-background hover:bg-foreground/90",
+ )}
+ >
+
+
+ {cfg.label}
+
+
+ {cfg.description}
+
-
-
- ))}
+
+ ))}
-
+
Subject
{
-
+
Details
- {message.length}/1000
+
+ {message.length}/1000
+
{
);
};
-export default FeedbackWidget;
\ No newline at end of file
+export default FeedbackWidget;
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
index f3b09d7..7a35acf 100644
--- a/apps/web/components/Footer.tsx
+++ b/apps/web/components/Footer.tsx
@@ -1,14 +1,14 @@
"use client";
-import React from 'react';
-import Link from 'next/link';
+import React from "react";
+import Link from "next/link";
const Footer = () => {
const scrollToTop = (e: React.MouseEvent) => {
- const href = (e.currentTarget as HTMLAnchorElement).getAttribute('href');
- if (href === '/') {
+ const href = (e.currentTarget as HTMLAnchorElement).getAttribute("href");
+ if (href === "/") {
e.preventDefault();
- window.scrollTo({ top: 0, behavior: 'smooth' });
+ window.scrollTo({ top: 0, behavior: "smooth" });
}
};
@@ -17,7 +17,6 @@ const Footer = () => {
// and the app's primary actions can never drift apart.
-
{/* Links Section */}
@@ -29,33 +28,103 @@ const Footer = () => {
CANVASFLOW
- The form builder for teams who want a working form in a minute and clean data by default.
+ The form builder for teams who want a working form in a minute and clean data by
+ default.
-
Directory
- Docs
- Learn More
- About
- Dashboard
- Your Forms
- Analytics
- Pricing
+
+ Directory
+
+
+ Docs
+
+
+ Learn More
+
+
+ About
+
+
+ Dashboard
+
+
+ Your Forms
+
+
+ Analytics
+
-
Protocol
- Privacy Policy
- Terms of Service
- Security
+
+ Protocol
+
+
+ Privacy Policy
+
+
+ Terms of Service
+
+
+ Security
+
@@ -68,9 +137,9 @@ const Footer = () => {
{/* Middle Huge Abstract Shapes (Cut off text) */}
-
- CANVASFLOW
-
+
+ CANVASFLOW
+
{/* Bottom Footer bar */}
@@ -80,7 +149,6 @@ const Footer = () => {
Created by CanvasFlow-Studio 20—26
-
);
diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx
index b60d1da..b436029 100644
--- a/apps/web/components/Navbar.tsx
+++ b/apps/web/components/Navbar.tsx
@@ -1,11 +1,11 @@
"use client";
-import React, { useEffect, useState } from 'react';
-import Link from 'next/link';
-import { motion, AnimatePresence } from 'motion/react';
-import { ChevronDown, Menu, X } from 'lucide-react';
-import { cn } from '~/lib/utils';
-import { useGetLoggedInUserInfo } from '~/hooks/api/auth';
+import React, { useEffect, useState } from "react";
+import Link from "next/link";
+import { motion, AnimatePresence } from "motion/react";
+import { ChevronDown, Menu, X } from "lucide-react";
+import { cn } from "~/lib/utils";
+import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
/* ── Nav content ───────────────────────────────────────────────────────
Held as data rather than inline JSX so the desktop dropdowns and the
@@ -16,82 +16,125 @@ type NavLinkItem = { title: string; desc: string; href: string };
const PLATFORM_GROUPS: { heading: string; items: NavLinkItem[] }[] = [
{
- heading: 'Products',
+ heading: "Products",
items: [
- { title: 'Field library', desc: 'Twelve field types, drag to reorder, edit inline', href: '/#fields' },
- { title: 'Visual form canvas', desc: 'Build, set required fields, and publish on one surface', href: '/#canvas' },
- { title: 'Response analytics', desc: 'Live dashboards the moment answers start landing', href: '/#analytics' },
- { title: 'Response stream', desc: 'Every submission in a table you can export', href: '/#responses' },
+ {
+ title: "Field library",
+ desc: "Twelve field types, drag to reorder, edit inline",
+ href: "/#fields",
+ },
+ {
+ title: "Visual form canvas",
+ desc: "Build, set required fields, and publish on one surface",
+ href: "/#canvas",
+ },
+ {
+ title: "Response analytics",
+ desc: "Live dashboards the moment answers start landing",
+ href: "/#analytics",
+ },
+ {
+ title: "Response stream",
+ desc: "Every submission in a table you can export",
+ href: "/#responses",
+ },
],
},
{
- heading: 'Capabilities',
+ heading: "Capabilities",
items: [
- { title: 'One question at a time', desc: 'A focused flow with a progress bar and inline validation', href: '/#how-it-works' },
- { title: 'Share by link or QR', desc: 'Publish, copy the link, or hand over a QR code', href: '/#responses' },
- { title: 'CSV export', desc: 'Take every response with you whenever you want', href: '/#analytics' },
- { title: 'Access controls', desc: 'Close a form, set an expiry, or cap submissions', href: '/#faq' },
+ {
+ title: "One question at a time",
+ desc: "A focused flow with a progress bar and inline validation",
+ href: "/#how-it-works",
+ },
+ {
+ title: "Share by link or QR",
+ desc: "Publish, copy the link, or hand over a QR code",
+ href: "/#responses",
+ },
+ {
+ title: "CSV export",
+ desc: "Take every response with you whenever you want",
+ href: "/#analytics",
+ },
+ {
+ title: "Access controls",
+ desc: "Close a form, set an expiry, or cap submissions",
+ href: "/#faq",
+ },
],
},
];
const PLATFORM_PROMO = {
- title: 'Build together',
- desc: 'Invite teammates with per-area roles and hand over ownership',
- href: '/#collaborate',
+ title: "Build together",
+ desc: "Invite teammates with per-area roles and hand over ownership",
+ href: "/#collaborate",
};
const SOLUTIONS_ITEMS: NavLinkItem[] = [
- { title: 'For product teams', desc: 'User research and feedback loops', href: '/#how-it-works' },
- { title: 'For marketing', desc: 'Lead capture and qualification', href: '/#how-it-works' },
- { title: 'For customer success', desc: 'Satisfaction ratings and follow-ups', href: '/#analytics' },
+ { title: "For product teams", desc: "User research and feedback loops", href: "/#how-it-works" },
+ { title: "For marketing", desc: "Lead capture and qualification", href: "/#how-it-works" },
+ {
+ title: "For customer success",
+ desc: "Satisfaction ratings and follow-ups",
+ href: "/#analytics",
+ },
];
const RESOURCES_ITEMS: NavLinkItem[] = [
- { title: 'Documentation', desc: 'A guide to every feature', href: '/docs' },
- { title: 'Learn more', desc: 'Every capability, end to end', href: '/learn-more' },
- { title: 'About', desc: 'Why CanvasFlow exists', href: '/about' },
- { title: 'Your forms', desc: 'Jump back into a draft', href: '/dashboard/sketches' },
- { title: 'FAQ', desc: 'The short answers, fast', href: '/#faq' },
+ { title: "Documentation", desc: "A guide to every feature", href: "/docs" },
+ { title: "Learn more", desc: "Every capability, end to end", href: "/learn-more" },
+ { title: "About", desc: "Why CanvasFlow exists", href: "/about" },
+ { title: "Your forms", desc: "Jump back into a draft", href: "/dashboard/sketches" },
+ { title: "FAQ", desc: "The short answers, fast", href: "/#faq" },
];
interface NavItemProps {
label: string;
to?: string;
children?: React.ReactNode;
- dropdownAlign?: 'left' | 'center' | 'right';
+ dropdownAlign?: "left" | "center" | "right";
}
-const NavItem = ({ label, to, children, dropdownAlign = 'left' }: NavItemProps) => {
+const NavItem = ({ label, to, children, dropdownAlign = "left" }: NavItemProps) => {
const [isOpen, setIsOpen] = useState(false);
- const alignClass =
- dropdownAlign === 'left' ? 'left-0' :
- dropdownAlign === 'right' ? 'right-0' :
- 'left-1/2 -translate-x-1/2';
+ const alignClass =
+ dropdownAlign === "left"
+ ? "left-0"
+ : dropdownAlign === "right"
+ ? "right-0"
+ : "left-1/2 -translate-x-1/2";
return (
- setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{to ? (
-
{label}
) : (
-
{label}
{children && (
-
+
)}
)}
@@ -119,8 +162,12 @@ const NavItem = ({ label, to, children, dropdownAlign = 'left' }: NavItemProps)
const MegaMenuItem = ({ title, desc, href }: { title: string; desc: string; href: string }) => (
-
{title}
-
{desc}
+
+ {title}
+
+
+ {desc}
+
);
@@ -132,8 +179,12 @@ const SimpleDropdown = ({ items }: { items: NavLinkItem[] }) => (
href={item.href}
className="group cursor-pointer p-3 rounded-lg hover:bg-[#f5f3ee] transition-colors"
>
-
{item.title}
-
{item.desc}
+
+ {item.title}
+
+
+ {item.desc}
+
))}
@@ -152,7 +203,7 @@ const PlatformMegaMenu = () => (
{group.heading}
@@ -175,8 +226,10 @@ const PlatformMegaMenu = () => (
/>
-
{PLATFORM_PROMO.title}
-
+
+ {PLATFORM_PROMO.title}
+
+
{PLATFORM_PROMO.desc}
@@ -211,7 +264,10 @@ const MobileSection = ({
>
{label}
{open && (
@@ -224,7 +280,7 @@ const MobileSection = ({
className="rounded-lg px-3 py-2.5 transition-colors hover:bg-[#f5f3ee]"
>
{item.title}
-
+
{item.desc}
@@ -236,8 +292,7 @@ const MobileSection = ({
};
/** Scallop shape for the strip beneath the nav bar, used as a mask. */
-const WAVE_MASK =
- `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 10'%3E%3Cpath d='M0,0 C5,10 15,10 20,0 Z' fill='%23000'/%3E%3C/svg%3E")`;
+const WAVE_MASK = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 10'%3E%3Cpath d='M0,0 C5,10 15,10 20,0 Z' fill='%23000'/%3E%3C/svg%3E")`;
const Navbar = () => {
const { userInfo: user } = useGetLoggedInUserInfo();
@@ -248,12 +303,12 @@ const Navbar = () => {
// it mounted underneath the restored desktop nav.
useEffect(() => {
if (!menuOpen) return;
- const mq = window.matchMedia('(min-width: 1024px)');
+ const mq = window.matchMedia("(min-width: 1024px)");
const onChange = () => {
if (mq.matches) setMenuOpen(false);
};
- mq.addEventListener('change', onChange);
- return () => mq.removeEventListener('change', onChange);
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
}, [menuOpen]);
return (
@@ -264,12 +319,11 @@ const Navbar = () => {
-
{/* Left Navigation — desktop only. The hover dropdowns it hosts
have no touch equivalent, so below lg the whole group is
replaced by the disclosure panel. */}
@@ -282,7 +336,11 @@ const Navbar = () => {
-
+
Enterprise
@@ -290,7 +348,11 @@ const Navbar = () => {
{/* Logo. Centred on desktop between the two nav groups; flush
left on mobile, where the hamburger takes the right. */}
-
+
CanvasFlow
@@ -301,18 +363,15 @@ const Navbar = () => {
-
- Pricing
-
-
-
- {user ? "Dashboard" : "Log in"}
-
+ {user ? "Dashboard" : "Log in"}
+
+
{user ? "Go to dashboard" : "Get started"}
@@ -324,14 +383,13 @@ const Navbar = () => {
setMenuOpen((v) => !v)}
- aria-label={menuOpen ? 'Close menu' : 'Open menu'}
+ aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
aria-controls="mobile-nav-panel"
className="lg:hidden -mr-2 flex h-10 w-10 items-center justify-center text-foreground"
>
{menuOpen ? : }
-
{/* Mobile panel */}
@@ -362,36 +420,27 @@ const Navbar = () => {
>
Enterprise
-
- Pricing
-
-
- {user ? 'Dashboard' : 'Log in'}
+ {user ? "Dashboard" : "Log in"}
- {user ? 'Go to dashboard' : 'Get started'}
+ {user ? "Go to dashboard" : "Get started"}
)}
-
+
{/* Wave bottom decoration */}
{/* Scalloped edge under the bar. The wave is applied as a mask and
filled with the nav token rather than baked into the SVG, so it
@@ -400,13 +449,13 @@ const Navbar = () => {
diff --git a/apps/web/components/NotFoundPanel.tsx b/apps/web/components/NotFoundPanel.tsx
index aa6d246..d2044a6 100644
--- a/apps/web/components/NotFoundPanel.tsx
+++ b/apps/web/components/NotFoundPanel.tsx
@@ -116,11 +116,7 @@ export function NotFoundPanel() {
>
{glitch}
-
+
{/* Copy */}
@@ -227,7 +223,10 @@ export function NotFoundPanel() {
color: "#fff",
}}
>
-
+
Back to home
@@ -250,7 +249,6 @@ export function NotFoundPanel() {
["About", "/about"],
["Your forms", "/dashboard/sketches"],
["Analytics", "/dashboard/analytics"],
- ["Pricing", "/dashboard/pricing"],
].map(([label, href]) => (
{label}
diff --git a/apps/web/components/RocketIcon.tsx b/apps/web/components/RocketIcon.tsx
index cd63dd5..8d7e7e7 100644
--- a/apps/web/components/RocketIcon.tsx
+++ b/apps/web/components/RocketIcon.tsx
@@ -6,134 +6,125 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface RocketHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface RocketProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const RocketIcon = forwardRef
(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e?: React.MouseEvent) => {
- if (!isControlled.current) controls.start("normal");
- else onMouseLeave?.(e as any);
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
- const rocketVariants: Variants = {
- normal: { y: 0, x: 0 },
- animate: {
- y: [0, -3, 0],
- x: [0, 3, 0],
- transition: {
- duration: 0.7 * duration,
- ease: "easeInOut",
- },
- },
- };
+ const rocketVariants: Variants = {
+ normal: { y: 0, x: 0 },
+ animate: {
+ y: [0, -3, 0],
+ x: [0, 3, 0],
+ transition: {
+ duration: 0.7 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
- const thrustVariants: Variants = {
- normal: { opacity: 1, scale: 1 },
- animate: {
- opacity: [1, 0.3, 1],
- scale: [1, 1.4, 1],
- transition: {
- duration: 0.4 * duration,
- ease: "easeInOut",
- },
- },
- };
+ const thrustVariants: Variants = {
+ normal: { opacity: 1, scale: 1 },
+ animate: {
+ opacity: [1, 0.3, 1],
+ scale: [1, 1.4, 1],
+ transition: {
+ duration: 0.4 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
- return (
-
-
-
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+
+
+
+ );
+ },
);
RocketIcon.displayName = "RocketIcon";
-export { RocketIcon };
\ No newline at end of file
+export { RocketIcon };
diff --git a/apps/web/components/ShareIcon.tsx b/apps/web/components/ShareIcon.tsx
index f69b2a1..fe6d1e6 100644
--- a/apps/web/components/ShareIcon.tsx
+++ b/apps/web/components/ShareIcon.tsx
@@ -6,131 +6,110 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface ShareIconHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface ShareIconProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const ShareIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e?: React.MouseEvent) => {
- if (!isControlled.current) controls.start("normal");
- else onMouseLeave?.(e as any);
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
- const nodeVariants = (delay: number): Variants => ({
- normal: {
- scale: 1,
- opacity: 1,
- },
- animate: {
- scale: [1, 1.15, 1],
- opacity: [0.7, 1],
- transition: {
- duration: 0.45 * duration,
- ease: [0.22, 1, 0.36, 1],
- delay,
- },
- },
- });
+ const nodeVariants = (delay: number): Variants => ({
+ normal: {
+ scale: 1,
+ opacity: 1,
+ },
+ animate: {
+ scale: [1, 1.15, 1],
+ opacity: [0.7, 1],
+ transition: {
+ duration: 0.45 * duration,
+ ease: [0.22, 1, 0.36, 1],
+ delay,
+ },
+ },
+ });
- const lineVariants: Variants = {
- normal: {
- opacity: 1,
- },
- animate: {
- opacity: [0.4, 1],
- transition: {
- duration: 0.6 * duration,
- ease: "easeInOut",
- },
- },
- };
+ const lineVariants: Variants = {
+ normal: {
+ opacity: 1,
+ },
+ animate: {
+ opacity: [0.4, 1],
+ transition: {
+ duration: 0.6 * duration,
+ ease: "easeInOut",
+ },
+ },
+ };
- return (
-
-
-
-
-
+ return (
+
+
+
+
+
-
-
-
-
- );
- },
+
+
+
+
+ );
+ },
);
ShareIcon.displayName = "ShareIcon";
-export { ShareIcon };
\ No newline at end of file
+export { ShareIcon };
diff --git a/apps/web/components/ShieldUserIcon.tsx b/apps/web/components/ShieldUserIcon.tsx
index d76577a..3495d12 100644
--- a/apps/web/components/ShieldUserIcon.tsx
+++ b/apps/web/components/ShieldUserIcon.tsx
@@ -6,139 +6,131 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface ShieldUserHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface ShieldUserProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const ShieldUserIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () => controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => controls.start("animate"),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e?: React.MouseEvent) => {
- if (!isControlled.current) controls.start("normal");
- else onMouseLeave?.(e as any);
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
- const shieldVariants: Variants = {
- normal: { strokeDashoffset: 0, opacity: 1 },
- animate: {
- strokeDashoffset: [120, 0],
- opacity: [0.3, 1],
- transition: { duration: 0.8 * duration, ease: "easeInOut" },
- },
- };
+ const shieldVariants: Variants = {
+ normal: { strokeDashoffset: 0, opacity: 1 },
+ animate: {
+ strokeDashoffset: [120, 0],
+ opacity: [0.3, 1],
+ transition: { duration: 0.8 * duration, ease: "easeInOut" },
+ },
+ };
- const bodyVariants: Variants = {
- normal: { opacity: 1, y: 0 },
- animate: {
- opacity: [0, 1],
- y: [6, 0],
- transition: {
- duration: 0.5 * duration,
- delay: 0.5,
- ease: "easeOut",
- },
- },
- };
+ const bodyVariants: Variants = {
+ normal: { opacity: 1, y: 0 },
+ animate: {
+ opacity: [0, 1],
+ y: [6, 0],
+ transition: {
+ duration: 0.5 * duration,
+ delay: 0.5,
+ ease: "easeOut",
+ },
+ },
+ };
- const headVariants: Variants = {
- normal: { scale: 1, opacity: 1 },
- animate: {
- scale: [0.5, 1.2, 1],
- opacity: [0, 1],
- transition: {
- duration: 0.6 * duration,
- delay: 0.3,
- ease: "easeOut",
- },
- },
- };
+ const headVariants: Variants = {
+ normal: { scale: 1, opacity: 1 },
+ animate: {
+ scale: [0.5, 1.2, 1],
+ opacity: [0, 1],
+ transition: {
+ duration: 0.6 * duration,
+ delay: 0.3,
+ ease: "easeOut",
+ },
+ },
+ };
- return (
-
-
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+
+
+ );
+ },
);
ShieldUserIcon.displayName = "ShieldUserIcon";
-export { ShieldUserIcon };
\ No newline at end of file
+export { ShieldUserIcon };
diff --git a/apps/web/components/ZapIcon.tsx b/apps/web/components/ZapIcon.tsx
index bee6687..c90e10a 100644
--- a/apps/web/components/ZapIcon.tsx
+++ b/apps/web/components/ZapIcon.tsx
@@ -6,128 +6,119 @@ import { motion, useAnimation, useReducedMotion } from "motion/react";
import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
export interface ZapHandle {
- startAnimation: () => void;
- stopAnimation: () => void;
+ startAnimation: () => void;
+ stopAnimation: () => void;
}
interface ZapProps extends HTMLMotionProps<"div"> {
- size?: number;
- duration?: number;
- isAnimated?: boolean;
+ size?: number;
+ duration?: number;
+ isAnimated?: boolean;
}
const ZapIcon = forwardRef(
- (
- {
- onMouseEnter,
- onMouseLeave,
- className,
- size = 24,
- duration = 1,
- isAnimated = true,
- ...props
- },
- ref,
- ) => {
- const controls = useAnimation();
- const reduced = useReducedMotion();
- const isControlled = useRef(false);
+ (
+ { onMouseEnter, onMouseLeave, className, size = 24, duration = 1, isAnimated = true, ...props },
+ ref,
+ ) => {
+ const controls = useAnimation();
+ const reduced = useReducedMotion();
+ const isControlled = useRef(false);
- useImperativeHandle(ref, () => {
- isControlled.current = true;
- return {
- startAnimation: () =>
- reduced ? controls.start("normal") : controls.start("animate"),
- stopAnimation: () => controls.start("normal"),
- };
- });
+ useImperativeHandle(ref, () => {
+ isControlled.current = true;
+ return {
+ startAnimation: () => (reduced ? controls.start("normal") : controls.start("animate")),
+ stopAnimation: () => controls.start("normal"),
+ };
+ });
- const handleEnter = useCallback(
- (e?: React.MouseEvent) => {
- if (!isAnimated || reduced) return;
- if (!isControlled.current) controls.start("animate");
- else onMouseEnter?.(e as any);
- },
- [controls, reduced, isAnimated, onMouseEnter],
- );
+ const handleEnter = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isAnimated || reduced) return;
+ if (!isControlled.current) controls.start("animate");
+ else onMouseEnter?.(e as any);
+ },
+ [controls, reduced, isAnimated, onMouseEnter],
+ );
- const handleLeave = useCallback(
- (e?: React.MouseEvent) => {
- if (!isControlled.current) controls.start("normal");
- else onMouseLeave?.(e as any);
- },
- [controls, onMouseLeave],
- );
+ const handleLeave = useCallback(
+ (e?: React.MouseEvent) => {
+ if (!isControlled.current) controls.start("normal");
+ else onMouseLeave?.(e as any);
+ },
+ [controls, onMouseLeave],
+ );
- const bodyVariants: Variants = {
- normal: {
- pathLength: 1,
- pathOffset: 0,
- opacity: 1,
- scale: 1,
- rotate: 0,
- },
- animate: {
- pathLength: [0, 1],
- pathOffset: [0.6, 0],
- opacity: [0.4, 1],
- scale: [0.92, 1.08, 1],
- rotate: [0, -4, 2, 0],
- transition: {
- pathLength: {
- duration: 0.45 * duration,
- ease: "easeOut",
- },
- pathOffset: {
- duration: 0.45 * duration,
- ease: "easeOut",
- },
- scale: {
- duration: 0.6 * duration,
- ease: "easeOut",
- },
- rotate: {
- duration: 0.6 * duration,
- ease: "easeOut",
- },
- opacity: {
- duration: 0.25 * duration,
- ease: "linear",
- },
- },
- },
- };
+ const bodyVariants: Variants = {
+ normal: {
+ pathLength: 1,
+ pathOffset: 0,
+ opacity: 1,
+ scale: 1,
+ rotate: 0,
+ },
+ animate: {
+ pathLength: [0, 1],
+ pathOffset: [0.6, 0],
+ opacity: [0.4, 1],
+ scale: [0.92, 1.08, 1],
+ rotate: [0, -4, 2, 0],
+ transition: {
+ pathLength: {
+ duration: 0.45 * duration,
+ ease: "easeOut",
+ },
+ pathOffset: {
+ duration: 0.45 * duration,
+ ease: "easeOut",
+ },
+ scale: {
+ duration: 0.6 * duration,
+ ease: "easeOut",
+ },
+ rotate: {
+ duration: 0.6 * duration,
+ ease: "easeOut",
+ },
+ opacity: {
+ duration: 0.25 * duration,
+ ease: "linear",
+ },
+ },
+ },
+ };
- return (
-
-
-
-
-
- );
- },
+ return (
+
+
+
+
+
+ );
+ },
);
ZapIcon.displayName = "ZapIcon";
-export { ZapIcon };
\ No newline at end of file
+export { ZapIcon };
diff --git a/apps/web/components/analytics/DeviceBreakdown.tsx b/apps/web/components/analytics/DeviceBreakdown.tsx
index c3d2777..91a66be 100644
--- a/apps/web/components/analytics/DeviceBreakdown.tsx
+++ b/apps/web/components/analytics/DeviceBreakdown.tsx
@@ -58,9 +58,7 @@ export function DeviceBreakdown({ deviceData }: DeviceBreakdownProps) {
{total}
-
- total
-
+ total
diff --git a/apps/web/components/analytics/FieldDropoff.tsx b/apps/web/components/analytics/FieldDropoff.tsx
index fa8fe3b..ad15761 100644
--- a/apps/web/components/analytics/FieldDropoff.tsx
+++ b/apps/web/components/analytics/FieldDropoff.tsx
@@ -109,8 +109,8 @@ export function FieldDropoff({ fieldCompletionRates, fields, submissions }: Fiel
Drop-off by field
- Share of people who started the form and answered each field. Select one to read
- its answers.
+ Share of people who started the form and answered each field. Select one to read its
+ answers.
{rows.length} fields
diff --git a/apps/web/components/analytics/MetricsGrid.tsx b/apps/web/components/analytics/MetricsGrid.tsx
index f4daabe..858b3a2 100644
--- a/apps/web/components/analytics/MetricsGrid.tsx
+++ b/apps/web/components/analytics/MetricsGrid.tsx
@@ -12,7 +12,7 @@ interface MetricsGridProps {
* mono supporting line.
*
* The supporting line carries a real derived number rather than a
- * "↑ 9.7% vs last week" style delta — the free-tier analytics payload has no
+ * "↑ 9.7% vs last week" style delta — the summary analytics payload has no
* previous-period figures to compare against, and inventing one would be
* worse than omitting it.
*
diff --git a/apps/web/components/analytics/ProGate.tsx b/apps/web/components/analytics/ProGate.tsx
deleted file mode 100644
index d927e86..0000000
--- a/apps/web/components/analytics/ProGate.tsx
+++ /dev/null
@@ -1,124 +0,0 @@
-"use client";
-
-import React from "react";
-import Link from "next/link";
-import { ArrowUpRight, Lock, Sparkles } from "lucide-react";
-
-interface ProGateProps {
- /** True when the viewer's plan does not include detailed analytics. */
- locked: boolean;
- title: string;
- body: string;
- children: React.ReactNode;
-}
-
-/**
- * Plan gate for the detailed analytics sections.
- *
- * When locked it renders a blurred *placeholder* and an upgrade prompt — never
- * the real content behind a blur. Two reasons that matters:
- *
- * 1. A CSS blur is decoration, not access control. Real data blurred in the
- * DOM is still readable in devtools and still arrived over the network, so
- * "blur the section" implemented literally would leak exactly the numbers
- * the plan is meant to sell.
- * 2. Blurred real content keeps its focusable children, so a keyboard user
- * tabs into controls they cannot see. Substituting a static placeholder
- * removes the problem instead of patching it with `aria-hidden`.
- *
- * The caller is responsible for not *fetching* the gated data when locked —
- * this component cannot un-request it. See `useGetProAnalytics` being passed an
- * empty id on the analytics page.
- */
-export function ProGate({ locked, title, body, children }: ProGateProps) {
- if (!locked) return <>{children}>;
-
- return (
-
- {/* Placeholder only — shaped like the real panels so the section reads as
- "there is something here", with no actual figures. */}
-
-
-
-
-
-
-
-
-
-
- Pro+ feature
-
-
- {title}
-
-
- {body}
-
-
-
- Upgrade to Pro+
-
-
-
-
-
- );
-}
-
-/** Inert stand-in for a gated section: panel shapes and bars, no numbers. */
-function GatePlaceholder() {
- const bars = [72, 58, 84, 41, 66, 30, 52];
-
- return (
-
- {[0, 1].map((panel) => (
-
-
-
-
- {bars.map((h, i) => (
-
- ))}
-
-
- {[0, 1, 2].map((i) => (
-
- ))}
-
-
- ))}
-
- );
-}
diff --git a/apps/web/components/analytics/QuestionDistribution.tsx b/apps/web/components/analytics/QuestionDistribution.tsx
index 296ca7a..9f6937d 100644
--- a/apps/web/components/analytics/QuestionDistribution.tsx
+++ b/apps/web/components/analytics/QuestionDistribution.tsx
@@ -57,10 +57,7 @@ function ChoiceBar({ option, index }: { option: OptionCount; index: number }) {
{option.value}
-
+
{option.count} ({option.percent}%)
@@ -85,7 +82,10 @@ function RatingStars({ avg }: { avg: number }) {
return (
-
@@ -103,23 +103,14 @@ function RatingDistBar({ dist }: { dist: RatingDistItem[] }) {
{dist.map((d) => (
-
+
{d.rating}
-
+
{d.count}
diff --git a/apps/web/components/analytics/StatsRow.tsx b/apps/web/components/analytics/StatsRow.tsx
index cc8c094..010a617 100644
--- a/apps/web/components/analytics/StatsRow.tsx
+++ b/apps/web/components/analytics/StatsRow.tsx
@@ -16,12 +16,7 @@ interface ChipDef {
icon: React.ElementType;
}
-export function StatsRow({
- peakDay,
- avgPerWeek,
- velocityFirst24h,
- returningRate,
-}: StatsRowProps) {
+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 },
diff --git a/apps/web/components/builder/BuilderHeader.tsx b/apps/web/components/builder/BuilderHeader.tsx
index 8b32181..23081e9 100644
--- a/apps/web/components/builder/BuilderHeader.tsx
+++ b/apps/web/components/builder/BuilderHeader.tsx
@@ -207,10 +207,14 @@ export function BuilderHeader({
{/* Preview / Share / Delete — visible on sm+ */}
+ {/* `?preview=1` makes the public form fillable and submittable without
+ writing anything: no response, no draft, no answer tracking. Without
+ it, an author testing their own form would record a response to it
+ and burn their one-per-visitor slot. */}
@@ -272,7 +276,7 @@ export function BuilderHeader({
className="cf-panel cf-raised absolute top-full right-0 z-50 mt-1.5 w-44 p-1.5"
>
setMenuOpen(false)}
className="cf-menu-item flex items-center gap-2.5 py-2! text-[13px]"
diff --git a/apps/web/components/builder/DeleteFormDialog.tsx b/apps/web/components/builder/DeleteFormDialog.tsx
index 67a5f72..c44dc2b 100644
--- a/apps/web/components/builder/DeleteFormDialog.tsx
+++ b/apps/web/components/builder/DeleteFormDialog.tsx
@@ -28,8 +28,8 @@ export function DeleteFormDialog({
Delete this form?
- “{formTitle}” {" "}
- and all its fields and submissions will be permanently removed. This cannot be undone.
+ “{formTitle}” and all its
+ fields and submissions will be permanently removed. This cannot be undone.
diff --git a/apps/web/components/builder/FieldInspector.tsx b/apps/web/components/builder/FieldInspector.tsx
index 75b1877..9fa35b5 100644
--- a/apps/web/components/builder/FieldInspector.tsx
+++ b/apps/web/components/builder/FieldInspector.tsx
@@ -2,9 +2,36 @@
import React from "react";
import { MousePointerClick, SlidersHorizontal, Trash2 } from "lucide-react";
+import { GitBranch, Pencil } from "lucide-react";
import { getFieldIcon } from "./FormFieldNode";
-export interface FieldInspectorProps {
+/**
+ * Segment assignment and branching for the selected question.
+ *
+ * Optional as a group: `FieldInspectorBody` is shared with the phone editor
+ * (`MobileFieldEditorSheet`), which doesn't carry the form-wide segment and
+ * rule state. When they're absent the section simply isn't rendered, so the
+ * mobile sheet keeps working unchanged rather than needing a parallel
+ * implementation.
+ *
+ * Branching is represented here only as sentences and a button. The controls
+ * themselves need far more room than a 288px rail can give — see
+ * `LogicDialog` — and a summary is what the inspector is actually good at:
+ * telling you what this question does without you having to open anything.
+ */
+export interface FieldFlowProps {
+ segmentOptions?: Array<{ id: string; label: string }>;
+ currentSegmentId?: string | null;
+ onChangeSegment?: (segmentId: string | null) => void;
+ /** One plain-English line per branch, in evaluation order. */
+ ruleSummaries?: string[];
+ /** Branches that are saved but incomplete, so the rail can say so. */
+ incompleteRuleCount?: number;
+ onEditBranching?: () => void;
+ isLastInSegment?: boolean;
+}
+
+export interface FieldInspectorProps extends FieldFlowProps {
selectedField: any;
label: string;
setLabel: (val: string) => void;
@@ -87,6 +114,13 @@ export function FieldInspectorBody({
optionsList,
setOptionsList,
updateLocal,
+ segmentOptions,
+ currentSegmentId,
+ onChangeSegment,
+ ruleSummaries,
+ incompleteRuleCount,
+ onEditBranching,
+ isLastInSegment,
}: Omit
) {
return (
@@ -352,6 +386,79 @@ export function FieldInspectorBody({
)}
+
+ {/* ── flow: which page this question is on, and where answers lead ── */}
+ {segmentOptions && segmentOptions.length > 0 && onChangeSegment && (
+
+
+ This question belongs to
+ onChangeSegment(e.target.value || null)}
+ className="cf-input h-9 cursor-pointer px-3 text-[13px]"
+ >
+ {/* Only offered while it's the actual state. Once a form is
+ segmented, "no segment" isn't a place an author means to put
+ a question — it would render ahead of segment 1. */}
+ {!currentSegmentId && Not in a segment }
+ {segmentOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ )}
+
+ {onEditBranching && (
+
+ {ruleSummaries && ruleSummaries.length > 0 ? (
+
+ {ruleSummaries.map((summary, i) => (
+
+ {summary}
+
+ ))}
+
+ ) : (
+
+ {isLastInSegment
+ ? "This is the last question of its segment — a good place to send people to different segments based on their answers."
+ : "Route people to different questions based on the answers so far."}
+
+ )}
+
+ {!!incompleteRuleCount && incompleteRuleCount > 0 && (
+
+ {incompleteRuleCount} {incompleteRuleCount === 1 ? "branch is" : "branches are"} not
+ finished and {incompleteRuleCount === 1 ? "does" : "do"} nothing yet.
+
+ )}
+
+
+ {ruleSummaries && ruleSummaries.length > 0 ? (
+ <>
+
+ Edit branching
+ >
+ ) : (
+ <>
+
+ Add branching
+ >
+ )}
+
+
+ )}
);
}
@@ -363,7 +470,7 @@ export function FieldInspector(props: FieldInspectorProps) {
if (!selectedField) {
return (
-
+
+
{/* header */}
diff --git a/apps/web/components/builder/FieldSidebar.tsx b/apps/web/components/builder/FieldSidebar.tsx
index 7bc9606..f9069e1 100644
--- a/apps/web/components/builder/FieldSidebar.tsx
+++ b/apps/web/components/builder/FieldSidebar.tsx
@@ -142,9 +142,7 @@ function FieldItem({
{field.label}
-
- {field.description}
-
+
{field.description}
);
diff --git a/apps/web/components/builder/FormFieldNode.tsx b/apps/web/components/builder/FormFieldNode.tsx
index f6f1943..bb2b50a 100644
--- a/apps/web/components/builder/FormFieldNode.tsx
+++ b/apps/web/components/builder/FormFieldNode.tsx
@@ -89,9 +89,7 @@ export const FormFieldNode = ({ data, selected }: { data: any; selected: boolean
{field.description && (
-
- {field.description}
-
+
{field.description}
)}
@@ -156,10 +154,7 @@ function FieldPreview({ field }: { field: any }) {
return (
{options.slice(0, 3).map((opt, i) => (
-
+
diff --git a/apps/web/components/builder/FormSettingsDialog.tsx b/apps/web/components/builder/FormSettingsDialog.tsx
index 6ec906d..285da19 100644
--- a/apps/web/components/builder/FormSettingsDialog.tsx
+++ b/apps/web/components/builder/FormSettingsDialog.tsx
@@ -1,9 +1,18 @@
"use client";
import React, { useState, useEffect } from "react";
-import { X, Calendar, ShieldAlert } from "lucide-react";
+import { X, Calendar, Lock, Mail, UserCheck, AtSign, Plus } from "lucide-react";
import { toast } from "sonner";
import { useUpdateFormSettings } from "~/hooks/api/form";
+import { normaliseDomain } from "~/lib/form-access";
+import { canRenderOnOnePage } from "~/lib/form-flow";
+
+type QuestionLayout = "AUTO" | "ONE_PER_PAGE" | "SEGMENT_PER_PAGE" | "ALL_AT_ONCE";
+
+/** Matches the service's cap, so the author is stopped here rather than by a
+ * validation error after pressing Save. */
+const MAX_DOMAINS = 20;
+const MAX_THANK_YOU = 2000;
interface FormSettingsDialogProps {
show: boolean;
@@ -12,12 +21,49 @@ interface FormSettingsDialogProps {
title: string;
description?: string | null;
isOpen: boolean;
- maxSubmissions?: number | null;
expiresAt?: any | null;
+ questionLayout?: QuestionLayout | null;
+ requireSignIn?: boolean | null;
+ collectRespondentEmail?: boolean | null;
+ oneResponsePerRespondent?: boolean | null;
+ allowedEmailDomains?: string[] | null;
+ thankYouMessage?: string | null;
} | null;
+ /** Drives the sentence explaining what AUTO resolves to for this form. */
+ segmentCount?: number;
+ /** Active branching rules. A form that branches can't be shown on one page. */
+ ruleCount?: number;
onClose: () => void;
}
+/**
+ * The four layouts, worded from the respondent's side rather than the
+ * schema's — an author is choosing what filling the form feels like, not
+ * naming an enum.
+ */
+const LAYOUT_CHOICES: Array<{ value: QuestionLayout; title: string; hint: string }> = [
+ {
+ value: "AUTO",
+ title: "Match the form's shape",
+ hint: "One question at a time until you add a second segment, then one segment per page.",
+ },
+ {
+ value: "ONE_PER_PAGE",
+ title: "One question per page",
+ hint: "Focused and conversational. Best for longer forms and for phones.",
+ },
+ {
+ value: "SEGMENT_PER_PAGE",
+ title: "One segment per page",
+ hint: "Each segment's questions together, with Next between them.",
+ },
+ {
+ value: "ALL_AT_ONCE",
+ title: "Everything on one page",
+ hint: "The whole form in a single scroll, like a classic web form.",
+ },
+];
+
const toDatetimeLocal = (d?: string | Date | null) => {
if (!d) return "";
const date = new Date(d);
@@ -35,10 +81,15 @@ function Toggle({
on,
onChange,
label,
+ disabled,
}: {
on: boolean;
onChange: (v: boolean) => void;
label: string;
+ /** Used for a switch another setting is holding on. Left visibly on and
+ * inert, rather than hidden: the author needs to see that the requirement
+ * applies, and why turning it off here wouldn't work. */
+ disabled?: boolean;
}) {
return (
onChange(!on)}
- className="cf-toggle"
+ aria-disabled={disabled}
+ disabled={disabled}
+ onClick={() => !disabled && onChange(!on)}
+ className="cf-toggle disabled:cursor-not-allowed disabled:opacity-60"
>
@@ -79,31 +132,118 @@ function Row({
);
}
-export function FormSettingsDialog({ show, form, onClose }: FormSettingsDialogProps) {
+export function FormSettingsDialog({
+ show,
+ form,
+ segmentCount = 0,
+ ruleCount = 0,
+ onClose,
+}: FormSettingsDialogProps) {
const { updateFormSettingsAsync, isPending } = useUpdateFormSettings();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
+ const [questionLayout, setQuestionLayout] = useState
("AUTO");
const [isOpen, setIsOpen] = useState(true);
- const [enableLimit, setEnableLimit] = useState(false);
- const [maxSubmissions, setMaxSubmissions] = useState("");
const [enableExpiration, setEnableExpiration] = useState(false);
const [expiresAt, setExpiresAt] = useState("");
+ const [requireSignIn, setRequireSignIn] = useState(false);
+ const [collectRespondentEmail, setCollectRespondentEmail] = useState(false);
+ const [oneResponsePerRespondent, setOneResponsePerRespondent] = useState(false);
+ const [restrictDomains, setRestrictDomains] = useState(false);
+ const [domains, setDomains] = useState([]);
+ const [domainInput, setDomainInput] = useState("");
+ const [thankYouMessage, setThankYouMessage] = useState("");
+
useEffect(() => {
if (form) {
setTitle(form.title);
setDescription(form.description || "");
+ setQuestionLayout(form.questionLayout ?? "AUTO");
setIsOpen(form.isOpen);
- setEnableLimit(!!form.maxSubmissions);
- setMaxSubmissions(form.maxSubmissions ?? "");
setEnableExpiration(!!form.expiresAt);
setExpiresAt(toDatetimeLocal(form.expiresAt));
+
+ setRequireSignIn(!!form.requireSignIn);
+ setCollectRespondentEmail(!!form.collectRespondentEmail);
+ setOneResponsePerRespondent(!!form.oneResponsePerRespondent);
+ // The toggle is derived from whether any domains are stored rather than
+ // kept as its own column — an empty allow-list and no restriction are the
+ // same thing, and storing both invites them to disagree.
+ setDomains(form.allowedEmailDomains ?? []);
+ setRestrictDomains((form.allowedEmailDomains?.length ?? 0) > 0);
+ setDomainInput("");
+ setThankYouMessage(form.thankYouMessage ?? "");
}
}, [form, show]);
if (!show || !form) return null;
+ /* Whether "everything on one page" is still on the table, decided by the same
+ function the renderer uses so the dialog can't offer a layout the renderer
+ would override. */
+ const onePageAllowed = canRenderOnOnePage(segmentCount, ruleCount);
+ const onePageBlockedReason =
+ ruleCount > 0 && segmentCount > 1
+ ? "This form branches and is split into segments, so it needs pages."
+ : ruleCount > 0
+ ? "This form uses branching, which needs a next page to send people to."
+ : "This form is split into segments, which are its pages.";
+
+ /* The dependency, stated once.
+ *
+ * Recording an email, holding someone to one response, and restricting by
+ * domain all need an account to read, so each of them turns sign-in on
+ * whether or not the author ticked it. Showing that here — rather than only
+ * applying it server-side — is the difference between a switch that looks
+ * off while behaving as on, and one the author can see is being held. */
+ const activeDomains = restrictDomains ? domains : [];
+ const signInImplied =
+ collectRespondentEmail || oneResponsePerRespondent || activeDomains.length > 0;
+ const signInEffective = requireSignIn || signInImplied;
+
+ const impliedBy = [
+ collectRespondentEmail && "recording emails",
+ oneResponsePerRespondent && "one response per person",
+ activeDomains.length > 0 && "the domain restriction",
+ ].filter(Boolean) as string[];
+
+ const addDomain = () => {
+ const candidate = normaliseDomain(domainInput);
+
+ if (!candidate) {
+ setDomainInput("");
+ return;
+ }
+ // A domain needs a dot and a plausible TLD. Without the check a typo like
+ // "gmail" would save happily and then match nothing, which reads as the
+ // restriction rejecting everyone for no reason.
+ if (!/^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(candidate)) {
+ toast.error(`"${domainInput.trim()}" doesn't look like a domain`);
+ return;
+ }
+ if (candidate.length > 255) {
+ toast.error("That domain is too long");
+ return;
+ }
+ if (domains.includes(candidate)) {
+ toast.error(`${candidate} is already on the list`);
+ setDomainInput("");
+ return;
+ }
+ if (domains.length >= MAX_DOMAINS) {
+ toast.error(`At most ${MAX_DOMAINS} domains`);
+ return;
+ }
+
+ setDomains((prev) => [...prev, candidate]);
+ setDomainInput("");
+ };
+
+ const removeDomain = (domain: string) =>
+ setDomains((prev) => prev.filter((d) => d !== domain));
+
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
@@ -111,14 +251,28 @@ export function FormSettingsDialog({ show, form, onClose }: FormSettingsDialogPr
return;
}
+ if (restrictDomains && domains.length === 0) {
+ toast.error("Add at least one domain, or turn the restriction off");
+ return;
+ }
+
try {
await updateFormSettingsAsync({
id: form.id,
title: title.trim(),
description: description.trim() || null,
isOpen,
- maxSubmissions: enableLimit && maxSubmissions !== "" ? Number(maxSubmissions) : null,
+ questionLayout,
expiresAt: enableExpiration && expiresAt ? new Date(expiresAt).toISOString() : null,
+
+ // The effective value, not the raw switch. The service derives the same
+ // thing, but sending what the dialog displayed means a reopened dialog
+ // shows the state the author left rather than one shifting under them.
+ requireSignIn: signInEffective,
+ collectRespondentEmail,
+ oneResponsePerRespondent,
+ allowedEmailDomains: activeDomains.length > 0 ? activeDomains : null,
+ thankYouMessage: thankYouMessage.trim() || null,
});
toast.success("Settings updated successfully");
onClose();
@@ -132,8 +286,15 @@ export function FormSettingsDialog({ show, form, onClose }: FormSettingsDialogPr
const summary = [
isOpen ? "Accepting" : "Closed",
enableExpiration && expiresAt ? "expires" : "no expiry",
- enableLimit && maxSubmissions !== "" ? `caps at ${maxSubmissions}` : "no cap",
- ].join(" · ");
+ LAYOUT_CHOICES.find((c) => c.value === questionLayout)?.title.toLowerCase() ?? "",
+ signInEffective ? "sign-in required" : "open to anyone",
+ oneResponsePerRespondent ? "1 response each" : "multiple responses",
+ activeDomains.length > 0
+ ? `${activeDomains.length} domain${activeDomains.length === 1 ? "" : "s"}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" · ");
return (
@@ -195,6 +356,90 @@ export function FormSettingsDialog({ show, form, onClose }: FormSettingsDialogPr
/>
+
+
Layout
+
+ {LAYOUT_CHOICES.map((choice) => {
+ const on = questionLayout === choice.value;
+ /* Offered only while it would actually be honoured. A
+ branching or segmented form is paginated by the renderer
+ whatever this says, and a switch that silently does nothing
+ is worse than one that explains why it can't. */
+ const blocked = choice.value === "ALL_AT_ONCE" && !onePageAllowed;
+
+ return (
+ setQuestionLayout(choice.value)}
+ className={`flex w-full items-start gap-2.5 border px-3 py-2.5 text-left transition-colors ${
+ blocked ? "cursor-not-allowed opacity-55" : "cursor-pointer"
+ }`}
+ style={{
+ borderColor: on ? "var(--cf-orange)" : "var(--cf-line-strong)",
+ background: on ? "var(--cf-cream)" : "var(--cf-cream-2)",
+ boxShadow: on && !blocked ? "3px 3px 0 0 var(--cf-orange)" : undefined,
+ }}
+ >
+
+
+
+ {choice.title}
+ {choice.value === "AUTO" && (
+
+ default
+
+ )}
+ {blocked && (
+
+ unavailable
+
+ )}
+
+
+ {blocked ? onePageBlockedReason : choice.hint}
+
+
+
+ );
+ })}
+
+
+ {/* Names what AUTO has actually chosen for this form. Without it
+ the default is the one option whose behaviour the author
+ can't see. */}
+ {questionLayout === "AUTO" && (
+
+ {segmentCount > 1
+ ? `This form has ${segmentCount} segments, so respondents get one segment per page.`
+ : "This form has one segment, so respondents get one question per page."}
+
+ )}
+
+ {/* The form was set to one page before it branched or was split.
+ The setting is kept rather than rewritten — remove the
+ branching and the author's choice comes back — so the honest
+ thing is to say it isn't in force right now. */}
+ {questionLayout === "ALL_AT_ONCE" && !onePageAllowed && (
+
+ Saved as one page, but not in use: {onePageBlockedReason} Respondents currently
+ get{" "}
+ {segmentCount > 1 ? "one segment per page" : "one question per page"}.
+
+ )}
+
+
Availability
@@ -224,37 +469,172 @@ export function FormSettingsDialog({ show, form, onClose }: FormSettingsDialogPr
/>
)}
+
+
+
+ {/* ── Who can respond ─────────────────────────────────────────
+ Ordered so the requirement everything else depends on comes
+ first, and each dependent setting sits below the thing it
+ switches on. */}
+
+
Who can respond
+
+
+
+
+
+
+
+
+
+
+
+
- {enableLimit && (
-
- setMaxSubmissions(e.target.value === "" ? "" : Number(e.target.value))
- }
- className="cf-input px-3 py-2 text-[13px]"
- />
+
+ {restrictDomains && (
+
+
+
setDomainInput(e.target.value)}
+ /* Enter adds the domain rather than submitting the
+ dialog, which is what a text input inside a form
+ does by default — and saving on Enter would strand
+ the domain the author had just typed. */
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === ",") {
+ e.preventDefault();
+ addDomain();
+ }
+ }}
+ className="cf-input px-3 py-2 text-[13px]"
+ placeholder="rishihood.edu.in"
+ aria-label="Add an email domain"
+ />
+
+
+ Add
+
+
+
+ {domains.length > 0 ? (
+
+ {domains.map((domain) => (
+
+
+ @{domain}
+ removeDomain(domain)}
+ aria-label={`Remove ${domain}`}
+ className="cursor-pointer text-(--cf-ink-soft) hover:text-(--cf-ink)"
+ >
+
+
+
+
+ ))}
+
+ ) : (
+
+ Add at least one domain, or turn the restriction off.
+
+ )}
+
+ {/* Subdomain behaviour, spelled out. It's the part an
+ author would otherwise have to discover by testing —
+ and the part that decides whether they need to list
+ every department separately. */}
+
+ Subdomains are included. rishihood.edu.in {" "}
+ accepts both kamlesh@rishihood.edu.in and{" "}
+ dittya@nst.rishihood.edu.in .
+
+
)}
+
+ {/* ── After submitting ── */}
+
+
+ Thank-you note
+
+
setThankYouMessage(e.target.value)}
+ rows={3}
+ maxLength={MAX_THANK_YOU}
+ className="cf-input resize-none px-3 py-2 text-[13px]"
+ placeholder="We'll be in touch by Friday..."
+ />
+
+
+ Shown below the standard confirmation, not instead of it — respondents still get
+ told their answer was recorded.
+
+ {thankYouMessage.length > 0 && (
+
+ {thankYouMessage.length}/{MAX_THANK_YOU}
+
+ )}
+
+
diff --git a/apps/web/components/builder/LogicDialog.tsx b/apps/web/components/builder/LogicDialog.tsx
new file mode 100644
index 0000000..26b3c02
--- /dev/null
+++ b/apps/web/components/builder/LogicDialog.tsx
@@ -0,0 +1,824 @@
+"use client";
+
+import React, { useMemo } from "react";
+import {
+ Copy,
+ GitBranch,
+ Plus,
+ TriangleAlert,
+ X,
+ ChevronUp,
+ ChevronDown,
+ Trash2,
+} from "lucide-react";
+import { ModalOverlay } from "~/components/ui/ModalOverlay";
+import {
+ MULTI_VALUE_OPERATORS,
+ VALUELESS_OPERATORS,
+ type FlowCondition,
+ type FlowRule,
+ type LogicAction,
+ type LogicMatch,
+ type LogicOperator,
+} from "~/lib/form-flow";
+import {
+ ACTION_LABELS,
+ OPERATOR_LABELS,
+ choicesForField,
+ describeRule,
+ lintFlow,
+ operatorsForFieldType,
+ type FlowIssue,
+ type FlowLabels,
+} from "~/lib/form-logic";
+import type { Flow } from "~/lib/form-flow";
+
+export interface LogicDialogProps {
+ open: boolean;
+ onClose: () => void;
+
+ /** The question whose branches are being edited. */
+ triggerFieldId: string;
+ triggerFieldLabel: string;
+
+ /** Whole-form context: the flow drives labels, pickers and linting. */
+ flow: Flow;
+ labels: FlowLabels;
+
+ rules: FlowRule[];
+ onAddRule: () => void;
+ onUpdateRule: (ruleId: string, patch: Partial
) => void;
+ onDuplicateRule: (ruleId: string) => void;
+ onMoveRule: (ruleId: string, direction: "up" | "down") => void;
+ onDeleteRule: (ruleId: string) => void;
+
+ onAddCondition: (ruleId: string) => void;
+ onUpdateCondition: (ruleId: string, conditionId: string, patch: Partial) => void;
+ onDeleteCondition: (ruleId: string, conditionId: string) => void;
+}
+
+const CONTROL = "cf-input h-9 px-2.5 text-[13px]";
+
+/**
+ * Branching editor for one question, in a dialog rather than the inspector.
+ *
+ * It lives here because it cannot fit anywhere else. A rule is a sentence with
+ * up to six interdependent parts per condition plus two outcomes, and the
+ * inspector rail is 288px wide — enough to clip the controls and to put a
+ * native dropdown on top of the row it belongs to. Giving it a full-width
+ * surface is what makes the feature usable at all; the inspector keeps a
+ * read-only summary and a button that opens this.
+ */
+export function LogicDialog({
+ open,
+ onClose,
+ triggerFieldId,
+ triggerFieldLabel,
+ flow,
+ labels,
+ rules,
+ onAddRule,
+ onUpdateRule,
+ onDuplicateRule,
+ onMoveRule,
+ onDeleteRule,
+ onAddCondition,
+ onUpdateCondition,
+ onDeleteCondition,
+}: LogicDialogProps) {
+ // Linted across the whole form, not just these rules: the problems worth
+ // reporting (unreachable questions, contradicting branches) are only visible
+ // from the form as a whole.
+ const issues = useMemo(() => (open ? lintFlow(flow, labels) : []), [open, flow, labels]);
+ const issuesByRule = useMemo(() => {
+ const map = new Map();
+ for (const issue of issues) {
+ if (!issue.ruleId) continue;
+ const list = map.get(issue.ruleId);
+ if (list) list.push(issue);
+ else map.set(issue.ruleId, [issue]);
+ }
+ return map;
+ }, [issues]);
+
+ const formIssues = issues.filter((i) => !i.ruleId);
+
+ if (!open) return null;
+
+ const questionOptions = flow.order.map((field, i) => ({
+ id: field.id,
+ label: `${i + 1}. ${field.label?.trim() || `Untitled ${field.type.replace("_", " ").toLowerCase()}`}`,
+ }));
+ const segmentOptions = flow.segments.map((segment, i) => ({
+ id: segment.id,
+ label: `${i + 1}. ${segment.title?.trim() || `Segment ${i + 1}`}`,
+ }));
+
+ return (
+
+
+
+
+
+
+ Branching after {triggerFieldLabel}
+
+
+
+
+ {rules.length} {rules.length === 1 ? "branch" : "branches"}
+
+
+
+
+
+
+
+
+
+ Branches are checked top to bottom after this question is answered. The first one that
+ decides wins. A branch with an otherwise always decides; one without it steps
+ aside when its conditions don't hold, and the next branch gets a turn.
+
+
+ {formIssues.length > 0 &&
}
+
+ {rules.length === 0 ? (
+
+
No branches yet
+
+ Send people different ways
+
+
+ Add a branch to route on the answers so far — one or more conditions, a place to go
+ when they hold, and another when they don't.
+
+
+ ) : (
+ rules.map((rule, idx) => (
+
+ ))
+ )}
+
+
+
+ Add branch
+
+
+
+
+ If no branch decides, the next question follows in order.
+
+ Done
+
+
+
+
+ );
+}
+
+/* ─── issues ─────────────────────────────────────────────────────────── */
+
+function IssueList({ issues, title }: { issues: FlowIssue[]; title?: string }) {
+ return (
+
+ {title &&
{title}
}
+ {issues.map((issue, i) => (
+
+
+ {issue.message}
+
+ ))}
+
+ );
+}
+
+/* ─── one branch ─────────────────────────────────────────────────────── */
+
+function RuleCard({
+ rule,
+ position,
+ isFirst,
+ isLast,
+ triggerFieldId,
+ flow,
+ labels,
+ questionOptions,
+ segmentOptions,
+ issues,
+ onUpdateRule,
+ onDuplicateRule,
+ onMoveRule,
+ onDeleteRule,
+ onAddCondition,
+ onUpdateCondition,
+ onDeleteCondition,
+}: {
+ rule: FlowRule;
+ position: number;
+ isFirst: boolean;
+ isLast: boolean;
+ triggerFieldId: string;
+ flow: Flow;
+ labels: FlowLabels;
+ questionOptions: Array<{ id: string; label: string }>;
+ segmentOptions: Array<{ id: string; label: string }>;
+ issues: FlowIssue[];
+} & Pick<
+ LogicDialogProps,
+ | "onUpdateRule"
+ | "onDuplicateRule"
+ | "onMoveRule"
+ | "onDeleteRule"
+ | "onAddCondition"
+ | "onUpdateCondition"
+ | "onDeleteCondition"
+>) {
+ const summary = describeRule(rule, labels);
+
+ return (
+
+ {/* header */}
+
+
+
+ Branch {position}
+
+ onUpdateRule(rule.id, { match })}
+ disabled={rule.conditions.length < 2}
+ />
+
+
+
+ onMoveRule(rule.id, "up")}
+ >
+
+
+ onMoveRule(rule.id, "down")}
+ >
+
+
+ onDuplicateRule(rule.id)}
+ >
+
+
+ onDeleteRule(rule.id)}
+ >
+
+
+
+
+
+
+ {/* conditions */}
+
+ {rule.conditions.length === 0 && (
+
+ Add at least one condition — a branch with none never runs.
+
+ )}
+
+ {rule.conditions.map((condition, i) => (
+
1}
+ onChange={(patch) => onUpdateCondition(rule.id, condition.id, patch)}
+ onDelete={() => onDeleteCondition(rule.id, condition.id)}
+ />
+ ))}
+
+ onAddCondition(rule.id)}
+ className="cf-add-dashed w-full cursor-pointer border border-dashed py-1.5 text-[12px]"
+ >
+ + Add condition
+
+
+
+ {/* outcomes */}
+
+ o.id !== triggerFieldId)}
+ segmentOptions={segmentOptions}
+ onChange={(patch) => onUpdateRule(rule.id, patch)}
+ keys={{
+ action: "action",
+ field: "targetFieldId",
+ segment: "targetSegmentId",
+ }}
+ />
+ o.id !== triggerFieldId)}
+ segmentOptions={segmentOptions}
+ allowNone
+ onChange={(patch) => onUpdateRule(rule.id, patch)}
+ keys={{
+ action: "elseAction",
+ field: "elseTargetFieldId",
+ segment: "elseTargetSegmentId",
+ }}
+ />
+
+
+ {/* live sentence — the same text the inspector shows */}
+
+ {summary}
+
+
+ {issues.length > 0 &&
}
+
+
+ );
+}
+
+/* ─── controls ───────────────────────────────────────────────────────── */
+
+function IconButton({
+ children,
+ label,
+ onClick,
+ disabled,
+ danger,
+}: {
+ children: React.ReactNode;
+ label: string;
+ onClick: () => void;
+ disabled?: boolean;
+ danger?: boolean;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** ALL vs ANY. Disabled with one condition, where the distinction is
+ * meaningless and offering it only invites the question. */
+function MatchToggle({
+ value,
+ onChange,
+ disabled,
+}: {
+ value: LogicMatch;
+ onChange: (v: LogicMatch) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ {(["ALL", "ANY"] as LogicMatch[]).map((option) => {
+ const on = value === option;
+ return (
+ onChange(option)}
+ className="cursor-pointer px-2 py-0.5 font-mono text-[10px] tracking-wider uppercase transition-colors disabled:cursor-not-allowed"
+ style={
+ on
+ ? { background: "var(--cf-ink)", color: "var(--cf-cream)" }
+ : { background: "transparent", color: "var(--cf-ink-soft)" }
+ }
+ >
+ {option === "ALL" ? "match all" : "match any"}
+
+ );
+ })}
+
+ );
+}
+
+function ConditionRow({
+ condition,
+ lead,
+ flow,
+ questionOptions,
+ canDelete,
+ onChange,
+ onDelete,
+}: {
+ condition: FlowCondition;
+ lead: string;
+ flow: Flow;
+ questionOptions: Array<{ id: string; label: string }>;
+ canDelete: boolean;
+ onChange: (patch: Partial) => void;
+ onDelete: () => void;
+}) {
+ const sourceField = flow.fieldById.get(condition.fieldId);
+ const choices = choicesForField(sourceField);
+ const operators = operatorsForFieldType(sourceField?.type);
+ const needsValue = !VALUELESS_OPERATORS.includes(condition.operator);
+ const isMulti = MULTI_VALUE_OPERATORS.includes(condition.operator);
+
+ return (
+ /* Stacks on a phone, inline from sm up.
+ *
+ * This was a single `flex-wrap` row with min-widths on each control, which
+ * technically wrapped but produced a ragged two-and-a-half-line block where
+ * the operator and its value ended up on different rows from each other.
+ * Below sm the three parts each take the full width, which reads as the
+ * sentence it is. */
+
+
+ {lead}
+
+
+ {/* which question's answer to read — any question, which is the point */}
+
{
+ const fieldId = e.target.value;
+ const next = flow.fieldById.get(fieldId);
+ const allowed = operatorsForFieldType(next?.type);
+ // Changing the source can invalidate the operator (a date question
+ // has no "contains"), so fall back to the first sensible one rather
+ // than leaving a combination that can never match.
+ onChange({
+ fieldId,
+ ...(allowed.includes(condition.operator)
+ ? {}
+ : { operator: allowed[0] as LogicOperator, value: null }),
+ });
+ }}
+ className={`${CONTROL} w-full sm:min-w-40 sm:flex-1`}
+ aria-label="Question to test"
+ >
+ {questionOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
{
+ const operator = e.target.value as LogicOperator;
+ const wasMulti = MULTI_VALUE_OPERATORS.includes(condition.operator);
+ const nowMulti = MULTI_VALUE_OPERATORS.includes(operator);
+ onChange({
+ operator,
+ // The operand's shape changes with the operator, so a stale value
+ // would be rejected by the CHECK constraint on save.
+ ...(VALUELESS_OPERATORS.includes(operator)
+ ? { value: null }
+ : wasMulti !== nowMulti
+ ? { value: nowMulti ? [] : "" }
+ : {}),
+ });
+ }}
+ className={`${CONTROL} w-full sm:w-auto sm:min-w-32`}
+ aria-label="Comparison"
+ >
+ {operators.map((op) => (
+
+ {OPERATOR_LABELS[op]}
+
+ ))}
+
+
+ {needsValue && (
+
+
+
+ )}
+
+ {/* Right-aligned on its own row when stacked, so it doesn't sit beneath a
+ full-width control looking like part of it. */}
+
+
+
+
+
+
+ );
+}
+
+/**
+ * The operand. Shape follows the operator and the question:
+ * · a checkbox list for "is any of" on a question with fixed choices, because
+ * typing three option labels by hand is how branching silently stops
+ * matching;
+ * · a dropdown for a single value on a question with choices;
+ * · otherwise a typed input matching the question (number, date, time).
+ */
+function ValueControl({
+ condition,
+ choices,
+ fieldType,
+ isMulti,
+ onChange,
+}: {
+ condition: FlowCondition;
+ choices: string[];
+ fieldType: string | undefined;
+ isMulti: boolean;
+ onChange: (patch: Partial) => void;
+}) {
+ if (isMulti) {
+ const selected = Array.isArray(condition.value) ? condition.value.map(String) : [];
+
+ if (choices.length > 0) {
+ return (
+
+ {choices.map((choice) => {
+ const on = selected.some((s) => s.toLowerCase() === choice.toLowerCase());
+ return (
+
+
+ onChange({
+ value: on
+ ? selected.filter((s) => s.toLowerCase() !== choice.toLowerCase())
+ : [...selected, choice],
+ })
+ }
+ />
+ {choice}
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+ onChange({
+ value: e.target.value
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ })
+ }
+ placeholder="value, other value"
+ className={CONTROL}
+ aria-label="Values to match, comma separated"
+ />
+ );
+ }
+
+ if (fieldType === "TOGGLE") {
+ return (
+ onChange({ value: e.target.value })}
+ className={CONTROL}
+ aria-label="Value"
+ >
+ Choose…
+ Yes / on
+ No / off
+
+ );
+ }
+
+ if (choices.length > 0) {
+ return (
+ onChange({ value: e.target.value })}
+ className={CONTROL}
+ aria-label="Value"
+ >
+ Choose an answer…
+ {choices.map((choice) => (
+
+ {choice}
+
+ ))}
+
+ );
+ }
+
+ const inputType =
+ fieldType === "NUMBER" || fieldType === "RATING" || fieldType === "SLIDER"
+ ? "number"
+ : fieldType === "DATE"
+ ? "date"
+ : fieldType === "TIME"
+ ? "time"
+ : "text";
+
+ return (
+ onChange({ value: e.target.value })}
+ placeholder="value"
+ className={CONTROL}
+ aria-label="Value"
+ />
+ );
+}
+
+/** One outcome: what happens, and where it goes. */
+function Outcome({
+ heading,
+ hint,
+ action,
+ targetFieldId,
+ targetSegmentId,
+ questionOptions,
+ segmentOptions,
+ allowNone,
+ onChange,
+ keys,
+}: {
+ heading: string;
+ hint: string;
+ action: LogicAction | null;
+ targetFieldId?: string | null;
+ targetSegmentId?: string | null;
+ questionOptions: Array<{ id: string; label: string }>;
+ segmentOptions: Array<{ id: string; label: string }>;
+ allowNone?: boolean;
+ onChange: (patch: Partial) => void;
+ keys: { action: keyof FlowRule; field: keyof FlowRule; segment: keyof FlowRule };
+}) {
+ const set = (patch: Record) => onChange(patch as Partial);
+
+ return (
+
+
+
+ {heading}
+
+ {hint}
+
+
+
{
+ const next = (e.target.value || null) as LogicAction | null;
+ // Clear the targets that no longer apply, so the row can't hold a
+ // destination its action doesn't use.
+ set({
+ [keys.action]: next,
+ [keys.field]: next === "JUMP_TO_FIELD" ? (targetFieldId ?? null) : null,
+ [keys.segment]: next === "JUMP_TO_SEGMENT" ? (targetSegmentId ?? null) : null,
+ });
+ }}
+ className={CONTROL}
+ aria-label={`${heading} action`}
+ >
+ {allowNone && Nothing — try the next branch }
+ {(Object.keys(ACTION_LABELS) as LogicAction[]).map((option) => (
+
+ {ACTION_LABELS[option]}
+
+ ))}
+
+
+ {action === "JUMP_TO_FIELD" && (
+
set({ [keys.field]: e.target.value || null })}
+ className={CONTROL}
+ aria-label={`${heading} target question`}
+ >
+ Choose a question…
+ {questionOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ )}
+
+ {action === "JUMP_TO_SEGMENT" && (
+
set({ [keys.segment]: e.target.value || null })}
+ className={CONTROL}
+ aria-label={`${heading} target segment`}
+ >
+ Choose a segment…
+ {segmentOptions.length === 0 && No segments yet }
+ {segmentOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/builder/SegmentPanel.tsx b/apps/web/components/builder/SegmentPanel.tsx
new file mode 100644
index 0000000..ff77218
--- /dev/null
+++ b/apps/web/components/builder/SegmentPanel.tsx
@@ -0,0 +1,249 @@
+"use client";
+
+import React from "react";
+import { ChevronDown, ChevronUp, Layers, Plus, Trash2 } from "lucide-react";
+
+export interface SegmentLike {
+ id: string;
+ title: string;
+ description?: string | null;
+ index: string | number;
+ _isNew?: boolean;
+}
+
+interface SegmentPanelProps {
+ segments: SegmentLike[];
+ /** How many questions sit in each segment, keyed by segment id. */
+ questionCounts: Record;
+ /** Questions not yet filed under any segment. Only non-zero on forms that
+ * have never been segmented. */
+ unassignedCount: number;
+ selectedSegmentId: string | null;
+ onSelectSegment: (id: string | null) => void;
+ onAddSegment: () => void;
+ onRenameSegment: (id: string, title: string) => void;
+ onMoveSegment: (id: string, direction: "up" | "down") => void;
+ onDeleteSegment: (id: string) => void;
+}
+
+/**
+ * Segment list for the builder — the form's pages, in order.
+ *
+ * Selecting a segment filters the canvas/outline to its questions, which is
+ * what makes segments usable at all: a twenty-question form spread over four
+ * pages is unreadable as one flat canvas.
+ *
+ * Reordering uses up/down buttons rather than drag, matching `FieldOutline`.
+ * Segment order decides which page follows which, so it needs to be
+ * adjustable without the precision a drag target demands.
+ */
+export function SegmentPanel({
+ segments,
+ questionCounts,
+ unassignedCount,
+ selectedSegmentId,
+ onSelectSegment,
+ onAddSegment,
+ onRenameSegment,
+ onMoveSegment,
+ onDeleteSegment,
+}: SegmentPanelProps) {
+ const totalQuestions =
+ unassignedCount + segments.reduce((sum, s) => sum + (questionCounts[s.id] ?? 0), 0);
+
+ return (
+
+
+
+
+ Segments
+
+ {segments.length || "none"}
+
+
+
+
+
+ {/* "All questions" resets the filter. Shown only when there's a
+ filter to reset, so an unsegmented form gains no extra chrome. */}
+ {segments.length > 0 && (
+
onSelectSegment(null)}
+ className={`flex w-full items-center justify-between border px-3 py-2 text-left text-[12.5px] transition-colors ${
+ selectedSegmentId === null
+ ? "border-(--cf-orange) bg-(--cf-cream-2)"
+ : "border-(--cf-line-strong) bg-(--cf-cream) hover:bg-(--cf-cream-2)"
+ }`}
+ >
+ All questions
+ {totalQuestions}
+
+ )}
+
+ {unassignedCount > 0 && segments.length > 0 && (
+
+ {unassignedCount} question{unassignedCount === 1 ? "" : "s"} not in a segment — they
+ come first.
+
+ )}
+
+ {segments.length === 0 ? (
+
+ This form is one continuous list. Add a segment to split it into pages and to branch
+ between them.
+
+ ) : (
+ segments.map((segment, idx) => (
+
onSelectSegment(segment.id)}
+ onRename={(title) => onRenameSegment(segment.id, title)}
+ onMove={(direction) => onMoveSegment(segment.id, direction)}
+ onDelete={() => onDeleteSegment(segment.id)}
+ />
+ ))
+ )}
+
+
+
+ {segments.length === 0 ? "Split into segments" : "Add segment"}
+
+
+
+ );
+}
+
+function SegmentRow({
+ segment,
+ position,
+ questionCount,
+ isFirst,
+ isLast,
+ selected,
+ onSelect,
+ onRename,
+ onMove,
+ onDelete,
+}: {
+ segment: SegmentLike;
+ position: number;
+ questionCount: number;
+ isFirst: boolean;
+ isLast: boolean;
+ selected: boolean;
+ onSelect: () => void;
+ onRename: (title: string) => void;
+ onMove: (direction: "up" | "down") => void;
+ onDelete: () => void;
+}) {
+ return (
+ /* The card is the click target, not a control inside it.
+ *
+ * Selection used to hang off two things only: the 28px number badge, and
+ * focusing the rename input. Clicking the card anywhere else — its body,
+ * the padding around the title, the row with the question count — did
+ * nothing, which reads as a broken segment list rather than as a small hit
+ * area. `onMouseDown` on the wrapper catches all of it, and runs before
+ * focus moves so clicking straight into the rename field selects the
+ * segment first.
+ *
+ * The buttons and the input stop propagation instead of relying on the
+ * wrapper ignoring them: reordering segment 2 while viewing segment 1
+ * shouldn't quietly change what the canvas is showing. */
+
+
+
+ {position}
+
+
+ {/* Rename in place. A separate dialog for one text field is more
+ clicks than the edit is worth. */}
+ onRename(e.target.value)}
+ onMouseDown={(e) => e.stopPropagation()}
+ placeholder={`Segment ${position}`}
+ aria-label={`Segment ${position} title`}
+ className="cf-input h-8 min-w-0 flex-1 px-2 text-[12.5px]"
+ />
+
+
+
+ {/* Announces the filter state, which the border alone can't convey to
+ a screen reader. */}
+
+ {selected ? "viewing · " : ""}
+ {questionCount} {questionCount === 1 ? "question" : "questions"}
+
+
+ e.stopPropagation()}
+ onClick={() => onMove("up")}
+ disabled={isFirst}
+ className="flex size-7 cursor-pointer items-center justify-center border border-(--cf-line-strong) bg-(--cf-cream) hover:bg-(--cf-cream-2) disabled:cursor-not-allowed disabled:opacity-30"
+ aria-label={`Move ${segment.title} up`}
+ >
+
+
+ e.stopPropagation()}
+ onClick={() => onMove("down")}
+ disabled={isLast}
+ className="flex size-7 cursor-pointer items-center justify-center border border-(--cf-line-strong) bg-(--cf-cream) hover:bg-(--cf-cream-2) disabled:cursor-not-allowed disabled:opacity-30"
+ aria-label={`Move ${segment.title} down`}
+ >
+
+
+ e.stopPropagation()}
+ onClick={onDelete}
+ className="flex size-7 cursor-pointer items-center justify-center border border-(--cf-line-strong) bg-(--cf-cream) text-(--cf-ink-soft) hover:border-red-500 hover:text-red-600"
+ aria-label={`Delete ${segment.title}`}
+ >
+
+
+
+
+
+ );
+}
diff --git a/apps/web/components/builder/ShareCollaboratorsDialog.tsx b/apps/web/components/builder/ShareCollaboratorsDialog.tsx
index efd3775..8d3a2d5 100644
--- a/apps/web/components/builder/ShareCollaboratorsDialog.tsx
+++ b/apps/web/components/builder/ShareCollaboratorsDialog.tsx
@@ -156,14 +156,13 @@ export function ShareCollaboratorsDialog({
Share · {formTitle}
-
+
{role}
-
+
@@ -198,10 +197,7 @@ export function ShareCollaboratorsDialog({
QR code
-
+
{/* eslint-disable-next-line @next/next/no-img-element */}
) : collaborators?.length === 0 ? (
-
+
No collaborators yet.
) : (
collaborators?.map((c) => (
-
+
{c.name || "Collaborator"}
@@ -336,9 +338,8 @@ export function ShareCollaboratorsDialog({
Transfer ownership
- Make{" "}
- {confirmTransferUserEmail} the new owner?
- You become an editor and cannot undo this yourself.
+ Make {confirmTransferUserEmail} the new
+ owner? You become an editor and cannot undo this yourself.
void;
+}
+
+function relativeTime(iso: string): string {
+ const then = new Date(iso).getTime();
+ if (!Number.isFinite(then)) return "earlier";
+
+ const minutes = Math.round((Date.now() - then) / 60000);
+ if (minutes < 1) return "just now";
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
+
+ const hours = Math.round(minutes / 60);
+ if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;
+
+ const days = Math.round(hours / 24);
+ return `${days} day${days === 1 ? "" : "s"} ago`;
+}
+
+/**
+ * Tells a signed-in respondent that their progress is being kept, and that a
+ * previous session was picked up.
+ *
+ * Both halves matter. Silently restoring answers is alarming — the form appears
+ * to already know things about you — so the resume is announced with a way to
+ * discard it. And an autosave nobody can see is indistinguishable from no
+ * autosave, which is what makes people afraid to close the tab.
+ */
+export function FormDraftNotice({ status, resumedFrom, onStartOver }: FormDraftNoticeProps) {
+ if (status === "idle" && !resumedFrom) return null;
+
+ return (
+
+
+ {status === "saving" && (
+ <>
+
+ Saving your progress…
+ >
+ )}
+ {status === "saved" && (
+ <>
+
+ Progress saved — you can close this and come back.
+ >
+ )}
+ {status === "idle" && resumedFrom && (
+ <>
+
+ Picked up where you left off {relativeTime(resumedFrom)}.
+ >
+ )}
+
+
+ {resumedFrom && (
+
+ Start over
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/forms/FormErrorState.tsx b/apps/web/components/forms/FormErrorState.tsx
index c752925..2ba88bd 100644
--- a/apps/web/components/forms/FormErrorState.tsx
+++ b/apps/web/components/forms/FormErrorState.tsx
@@ -2,14 +2,29 @@
import React from "react";
import Link from "next/link";
-import { ArrowUpRight } from "lucide-react";
+import { ArrowUpRight, RotateCcw } from "lucide-react";
interface FormErrorStateProps {
- type: "not-found" | "draft-mode" | "already-submitted" | "closed" | "expired" | "limit-reached";
+ type:
+ | "not-found"
+ | "draft-mode"
+ | "already-submitted"
+ | "closed"
+ | "expired"
+ | "sign-in-required"
+ | "domain-not-allowed";
+ /** Domains the form accepts, listed so the respondent knows which account to
+ * use rather than guessing. */
+ allowedDomains?: string[];
+ /** Where to send someone who needs to sign in — carries a return path so they
+ * land back on the form rather than the dashboard. */
+ signInHref?: string;
+ /** Offered when the form takes more than one response. */
+ onRespondAgain?: () => void;
}
/**
- * The six states a respondent can land on instead of the form.
+ * The five states a respondent can land on instead of the form.
*
* Held as a lookup rather than the nested ternary chain this used to be —
* six branches deep, it had become hard to read and easy to mis-nest.
@@ -31,23 +46,34 @@ const STATES: Record<
"already-submitted": {
eyebrow: "Already submitted",
title: "You've responded to this form",
- body: "Each visitor can submit this form once. Thanks — we already have your response on file.",
+ // Deliberately not "this form accepts one response per person". That's the
+ // usual reason for landing here, but the same screen also catches a
+ // duplicate submit racing on one idempotency key, which can happen on a
+ // form that does accept several responses — and stating a policy that isn't
+ // in force is worse than not mentioning it.
+ body: "Thanks — we already have your response on file, so there's no need to send it again.",
+ },
+ "sign-in-required": {
+ eyebrow: "Sign in",
+ title: "This form needs you signed in",
+ body: "The author asked for responses from signed-in accounts, so we can tell respondents apart. Your answers aren't submitted until you do.",
+ },
+ "domain-not-allowed": {
+ eyebrow: "Wrong account",
+ title: "This form is limited to a specific organisation",
+ body: "Your signed-in email isn't in a domain the author accepts. Sign in with your organisation account and try again.",
},
closed: {
eyebrow: "Closed",
- title: "Form is closed",
- body: "The author has closed this form to new responses.",
+ title: "This form is not accepting responses",
+ body: "The author has closed it to new submissions. If you were asked to fill it in, check with whoever sent you the link.",
},
expired: {
eyebrow: "Expired",
- title: "Form has expired",
- body: "This form has passed its expiration date and is no longer accepting submissions.",
- },
- "limit-reached": {
- eyebrow: "Limit reached",
- title: "Submission limit reached",
- body: "This form has reached its maximum allowed number of submissions.",
+ title: "This form is no longer accepting responses",
+ body: "It passed its closing date, so submissions are closed. If you were asked to fill it in, check with whoever sent you the link.",
},
+
"not-found": {
eyebrow: "Not found",
title: "We can't find this form",
@@ -55,8 +81,14 @@ const STATES: Record<
},
};
-export function FormErrorState({ type }: FormErrorStateProps) {
+export function FormErrorState({
+ type,
+ allowedDomains,
+ signInHref,
+ onRespondAgain,
+}: FormErrorStateProps) {
const config = STATES[type];
+ const hasPrimaryAction = !!signInHref || !!onRespondAgain;
return (
{config.title}
-
- {config.body}
-
-
- Visit CanvasFlow
-
-
+
{config.body}
+
+ {/* Naming the accepted domains turns "wrong account" from a dead end
+ into an instruction. Subdomains are covered by each entry, which is
+ why one line can admit both @uni.edu and @dept.uni.edu. */}
+ {allowedDomains && allowedDomains.length > 0 && (
+
+
Accepted domains
+
+ {allowedDomains.map((domain) => (
+
+ @{domain}
+
+ ))}
+
+
+ )}
+
+ {signInHref && (
+
+ Sign in to continue
+
+
+ )}
+
+ {onRespondAgain && (
+
+
+ Submit another response
+
+ )}
+
+ {/* Demoted to a text link whenever the screen already offers the action
+ the respondent actually came for. Two filled buttons of equal weight
+ would make "Visit CanvasFlow" compete with "Sign in". */}
+ {hasPrimaryAction ? (
+
+
+ Visit CanvasFlow
+
+
+ ) : (
+
+ Visit CanvasFlow
+
+
+ )}
);
diff --git a/apps/web/components/forms/FormPreviewBanner.tsx b/apps/web/components/forms/FormPreviewBanner.tsx
new file mode 100644
index 0000000..07eb7f1
--- /dev/null
+++ b/apps/web/components/forms/FormPreviewBanner.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import React from "react";
+import { Eye } from "lucide-react";
+
+/**
+ * Sticky notice shown when a form is opened with `?preview=1`.
+ *
+ * Deliberately loud and un-dismissable. Preview lets the author fill the form
+ * and press Submit, and the one thing they must not conclude from a working
+ * Submit button is that a response was recorded. A quiet note at the top of a
+ * long form would be scrolled past long before then, so this stays pinned for
+ * the whole session.
+ */
+export function FormPreviewBanner() {
+ return (
+
+
+
+
+ Preview
+
+
+ Fill it in and submit to test the flow — nothing is saved and no response is recorded.
+
+
+
+ );
+}
diff --git a/apps/web/components/forms/FormQuestion.tsx b/apps/web/components/forms/FormQuestion.tsx
index 5ea6ac5..9ec53d0 100644
--- a/apps/web/components/forms/FormQuestion.tsx
+++ b/apps/web/components/forms/FormQuestion.tsx
@@ -14,14 +14,42 @@ interface FormField {
}
interface FormQuestionProps {
- currentField: FormField | undefined;
- currentQuestionIndex: number;
+ /**
+ * The questions on this page.
+ *
+ * Was a single `currentField`. A page holds one question when the form is
+ * laid out one-at-a-time and a whole segment's worth when it isn't, and
+ * those differ only in how many cards are stacked above the same Next
+ * button — not enough to justify a second component that would then have to
+ * be kept in step with this one's sixteen input types.
+ */
+ fields: FormField[];
+ /** Position of each question within the whole form, for the Q01 eyebrow.
+ * Page-relative numbering would restart at 1 on every page. */
+ questionNumbers: Record;
totalQuestions: number;
answers: Record;
isPending: boolean;
handleFieldChange: (fieldId: string, value: any) => void;
handleNext: () => void;
handleBack: () => void;
+ /* ── Branching-aware navigation ──────────────────────────────────────
+ *
+ * These used to be derived here: `isLast` was `index === total - 1` and
+ * Back showed whenever `index > 0`. Neither survives conditional logic.
+ * A branch can finish the form on question 2 of 6, and after a jump the
+ * question you came from is whatever the path says, not `index - 1`. Both
+ * answers now come from the resolver, which is the only thing that knows
+ * the route this respondent actually took. */
+ isLast: boolean;
+ canGoBack: boolean;
+ /** e.g. "Segment 2 of 3 · Your details". Absent on unsegmented forms. */
+ segmentLabel?: string | null;
+ /** The segment's own description, shown once above its questions. */
+ segmentDescription?: string | null;
+ /** Overrides the forward button's text. A multi-question page says "Next"
+ * about a page, not a question. */
+ nextLabel?: string;
}
/* ── Restyled from the supplied reference ───────────────────────────────
@@ -61,329 +89,361 @@ const CHOICE_BASE =
"w-full flex items-center gap-3 border px-4 py-3.5 text-[15px] text-left transition-colors cursor-pointer";
export function FormQuestion({
- currentField,
- currentQuestionIndex,
+ fields,
+ questionNumbers,
totalQuestions,
answers,
isPending,
handleFieldChange,
handleNext,
handleBack,
+ isLast,
+ canGoBack,
+ segmentLabel,
+ segmentDescription,
+ nextLabel,
}: FormQuestionProps) {
- if (!currentField) return null;
-
- const isLast = currentQuestionIndex === totalQuestions - 1;
- const options: string[] = Array.isArray(currentField.options)
- ? currentField.options
- : (currentField.options as any)?.choices || [];
-
- const value = answers[currentField.id];
- const inputId = `field-${currentField.id}`;
-
- /** Selected/unselected treatment shared by every choice control. */
- const choiceStyle = (on: boolean): React.CSSProperties =>
- on
- ? {
- borderColor: "var(--cf-ink)",
- background: "var(--cf-ink)",
- color: "var(--cf-cream)",
- boxShadow: "3px 3px 0 0 var(--cf-line-strong)",
- }
- : { borderColor: "var(--cf-line-strong)", background: "#fff" };
+ if (fields.length === 0) return null;
return (
-
- {/* ── question card ── */}
-
- {/* Ruled title bar, as on the builder's field nodes. */}
-
-
- Q{String(currentQuestionIndex + 1).padStart(2, "0")} / {String(totalQuestions).padStart(2, "0")}
-
- {currentField.isRequired && (
-
- Required
-
+ /* Keyed on the page's first field rather than a position. Two different
+ pages can occupy the same position on different branches, and keying on
+ the number would skip the entry animation when that happens. */
+
+ {/* Segment caption. Only rendered for segmented forms, so a plain
+ linear form looks exactly as it did before. */}
+ {segmentLabel && (
+
+
+ {segmentLabel}
+
+ {segmentDescription && (
+
+ {segmentDescription}
+
)}
-
-
- {/* heading */}
-
-
{
+ const options: string[] = Array.isArray(currentField.options)
+ ? currentField.options
+ : (currentField.options as any)?.choices || [];
+
+ const value = answers[currentField.id];
+ const inputId = `field-${currentField.id}`;
+ const questionNumber = questionNumbers[currentField.id] ?? indexOnPage + 1;
+
+ /** Selected/unselected treatment shared by every choice control. */
+ const choiceStyle = (on: boolean): React.CSSProperties =>
+ on
+ ? {
+ borderColor: "var(--cf-ink)",
+ background: "var(--cf-ink)",
+ color: "var(--cf-cream)",
+ boxShadow: "3px 3px 0 0 var(--cf-line-strong)",
+ }
+ : { borderColor: "var(--cf-line-strong)", background: "#fff" };
+
+ return (
+
+ {/* ── question card ── */}
+
- {currentField.label}
-
- {currentField.description && (
-
- {currentField.description}
-
- )}
-
-
- {/* ── input ── */}
-
- {currentField.type === "TEXT" && (
-
handleFieldChange(currentField.id, e.target.value)}
- className={INPUT_CLS}
- style={FIELD_BOX_STYLE}
- />
- )}
-
- {currentField.type === "TEXTAREA" && (
-
handleFieldChange(currentField.id, e.target.value)}
- rows={5}
- className="w-full resize-none border bg-white px-4 py-3.5 text-[16px] text-(--cf-ink) placeholder:text-(--cf-ink-soft)/55 focus:shadow-[3px_3px_0_0_var(--cf-line-strong)] focus:outline-none"
- style={FIELD_BOX_STYLE}
- />
- )}
-
- {currentField.type === "EMAIL" && (
- handleFieldChange(currentField.id, e.target.value)}
- className={INPUT_CLS}
- style={FIELD_BOX_STYLE}
- autoComplete="email"
- />
- )}
-
- {currentField.type === "NUMBER" && (
- handleFieldChange(currentField.id, e.target.value)}
- className={`${INPUT_CLS} tabular-nums`}
- style={FIELD_BOX_STYLE}
- />
- )}
-
- {currentField.type === "PHONE" && (
- handleFieldChange(currentField.id, e.target.value)}
- className={INPUT_CLS}
- style={FIELD_BOX_STYLE}
- autoComplete="tel"
- />
- )}
-
- {currentField.type === "URL" && (
- handleFieldChange(currentField.id, e.target.value)}
- className={INPUT_CLS}
- style={FIELD_BOX_STYLE}
- />
- )}
-
- {/* Radio semantics, so arrow keys and screen readers behave. */}
- {currentField.type === "SELECT" && (
-
- {options.map((opt, i) => {
- const on = value === opt;
- return (
- handleFieldChange(currentField.id, opt)}
- className={CHOICE_BASE}
- style={choiceStyle(on)}
- >
-
- {on && "●"}
-
- {opt}
-
- );
- })}
-
- )}
-
- {currentField.type === "CHECKBOX" && (
-
- {options.map((opt, i) => {
- const selected: string[] = value || [];
- const on = selected.includes(opt);
- return (
-
- handleFieldChange(
- currentField.id,
- on ? selected.filter((c) => c !== opt) : [...selected, opt],
- )
- }
- className={CHOICE_BASE}
- style={choiceStyle(on)}
- >
-
- {on && "✓"}
-
- {opt}
-
- );
- })}
-
- )}
-
- {currentField.type === "RATING" && (
+ {/* Ruled title bar, as on the builder's field nodes. */}
- {Array.from({ length: (currentField.options as any)?.max || 5 }).map((_, i) => {
- const score = i + 1;
- const on = (value || 0) >= score;
- return (
- handleFieldChange(currentField.id, score)}
- className="cursor-pointer p-1 transition-transform hover:scale-125"
- aria-label={`Rate ${score}`}
+
+ Q{String(questionNumber).padStart(2, "0")} /{" "}
+ {String(totalQuestions).padStart(2, "0")}
+
+ {currentField.isRequired && (
+
+ Required
+
+ )}
+
+
+
+ {/* heading */}
+
+
+ {currentField.label}
+
+ {currentField.description && (
+
+ {currentField.description}
+
+ )}
+
+
+ {/* ── input ── */}
+
+ {currentField.type === "TEXT" && (
+
handleFieldChange(currentField.id, e.target.value)}
+ className={INPUT_CLS}
+ style={FIELD_BOX_STYLE}
+ />
+ )}
+
+ {currentField.type === "TEXTAREA" && (
+
handleFieldChange(currentField.id, e.target.value)}
+ rows={5}
+ className="w-full resize-none border bg-white px-4 py-3.5 text-[16px] text-(--cf-ink) placeholder:text-(--cf-ink-soft)/55 focus:shadow-[3px_3px_0_0_var(--cf-line-strong)] focus:outline-none"
+ style={FIELD_BOX_STYLE}
+ />
+ )}
+
+ {currentField.type === "EMAIL" && (
+ handleFieldChange(currentField.id, e.target.value)}
+ className={INPUT_CLS}
+ style={FIELD_BOX_STYLE}
+ autoComplete="email"
+ />
+ )}
+
+ {currentField.type === "NUMBER" && (
+ handleFieldChange(currentField.id, e.target.value)}
+ className={`${INPUT_CLS} tabular-nums`}
+ style={FIELD_BOX_STYLE}
+ />
+ )}
+
+ {currentField.type === "PHONE" && (
+ handleFieldChange(currentField.id, e.target.value)}
+ className={INPUT_CLS}
+ style={FIELD_BOX_STYLE}
+ autoComplete="tel"
+ />
+ )}
+
+ {currentField.type === "URL" && (
+ handleFieldChange(currentField.id, e.target.value)}
+ className={INPUT_CLS}
+ style={FIELD_BOX_STYLE}
+ />
+ )}
+
+ {/* Radio semantics, so arrow keys and screen readers behave. */}
+ {currentField.type === "SELECT" && (
+
+ {options.map((opt, i) => {
+ const on = value === opt;
+ return (
+ handleFieldChange(currentField.id, opt)}
+ className={CHOICE_BASE}
+ style={choiceStyle(on)}
+ >
+
+ {on && "●"}
+
+ {opt}
+
+ );
+ })}
+
+ )}
+
+ {currentField.type === "CHECKBOX" && (
+
+ {options.map((opt, i) => {
+ const selected: string[] = value || [];
+ const on = selected.includes(opt);
+ return (
+
+ handleFieldChange(
+ currentField.id,
+ on ? selected.filter((c) => c !== opt) : [...selected, opt],
+ )
+ }
+ className={CHOICE_BASE}
+ style={choiceStyle(on)}
+ >
+
+ {on && "✓"}
+
+ {opt}
+
+ );
+ })}
+
+ )}
+
+ {currentField.type === "RATING" && (
+
- {
+ const score = i + 1;
+ const on = (value || 0) >= score;
+ return (
+ handleFieldChange(currentField.id, score)}
+ className="cursor-pointer p-1 transition-transform hover:scale-125"
+ aria-label={`Rate ${score}`}
+ >
+
+
+ );
+ },
+ )}
+
+ )}
+
+ {currentField.type === "DATE" && (
+
+ handleFieldChange(currentField.id, e.target.value)}
+ className={`${INPUT_CLS} pr-12`}
+ style={FIELD_BOX_STYLE}
/>
-
- );
- })}
-
- )}
-
- {currentField.type === "DATE" && (
-
- handleFieldChange(currentField.id, e.target.value)}
- className={`${INPUT_CLS} pr-12`}
- style={FIELD_BOX_STYLE}
- />
-
-
- )}
-
- {currentField.type === "TIME" && (
-
- handleFieldChange(currentField.id, e.target.value)}
- className={`${INPUT_CLS} pr-12`}
- style={FIELD_BOX_STYLE}
- />
-
-
- )}
+
+
+ )}
+
+ {currentField.type === "TIME" && (
+
+ handleFieldChange(currentField.id, e.target.value)}
+ className={`${INPUT_CLS} pr-12`}
+ style={FIELD_BOX_STYLE}
+ />
+
+
+ )}
- {/* Two drawn buttons rather than a switch — the reference's
+ {/* Two drawn buttons rather than a switch — the reference's
`yes_no` treatment. `value` is a real boolean here, so the
comparison is explicit to keep `undefined` from lighting up
the "No" side before the default is applied. */}
- {currentField.type === "TOGGLE" && (
-
- {[
- { on: true, label: (currentField.options as any)?.activeLabel || "Yes" },
- { on: false, label: (currentField.options as any)?.inactiveLabel || "No" },
- ].map((choice) => {
- const selected = value === choice.on;
- return (
-
handleFieldChange(currentField.id, choice.on)}
- className="flex-1 cursor-pointer border px-4 py-4 text-[16px] font-medium transition-colors"
- style={choiceStyle(selected)}
- >
- {choice.label}
-
- );
- })}
+ {currentField.type === "TOGGLE" && (
+
+ {[
+ { on: true, label: (currentField.options as any)?.activeLabel || "Yes" },
+ { on: false, label: (currentField.options as any)?.inactiveLabel || "No" },
+ ].map((choice) => {
+ const selected = value === choice.on;
+ return (
+ handleFieldChange(currentField.id, choice.on)}
+ className="flex-1 cursor-pointer border px-4 py-4 text-[16px] font-medium transition-colors"
+ style={choiceStyle(selected)}
+ >
+ {choice.label}
+
+ );
+ })}
+
+ )}
+
- )}
-
-
-
+
+
+ );
+ })}
{/* ── actions ── */}
{/* Next is the wide one and sits right, so the thumb lands on it. */}
- {currentQuestionIndex > 0 && (
+ {canGoBack && (
- {isLast ? (isPending ? "Submitting..." : "Submit") : "Next"}
+ {isLast ? (isPending ? "Submitting..." : "Submit") : (nextLabel ?? "Next")}
diff --git a/apps/web/components/forms/FormThankYou.tsx b/apps/web/components/forms/FormThankYou.tsx
index 00abf90..ba0b497 100644
--- a/apps/web/components/forms/FormThankYou.tsx
+++ b/apps/web/components/forms/FormThankYou.tsx
@@ -2,7 +2,7 @@
import React from "react";
import Link from "next/link";
-import { ArrowUpRight, Star } from "lucide-react";
+import { ArrowUpRight, RotateCcw, Star } from "lucide-react";
import { toast } from "sonner";
interface SummaryField {
@@ -17,6 +17,11 @@ interface FormThankYouProps {
/** Fields in display order, for the response summary. */
fields?: SummaryField[];
answers?: Record
;
+ /** The author's own note, shown in addition to the app's confirmation rather
+ * than replacing it — the respondent still needs to know it landed. */
+ customMessage?: string | null;
+ /** Offered when the form accepts more than one response. */
+ onRespondAgain?: () => void;
}
/**
@@ -38,6 +43,8 @@ export function FormThankYou({
setSiteRating,
fields = [],
answers = {},
+ customMessage,
+ onRespondAgain,
}: FormThankYouProps) {
/** Render an answer of any supported field type as a readable string. */
const display = (field: SummaryField): string => {
@@ -104,6 +111,35 @@ export function FormThankYou({
Your response has been recorded. You can close this tab whenever you're ready.
+
+ {/* The author's note, below the app's confirmation rather than instead
+ of it. Whatever they wrote — "we'll email you Monday", a link to the
+ next step — is additional context; the respondent still needs the
+ plain fact that the submission landed, and that isn't the author's
+ job to remember to say. `whitespace-pre-line` so the paragraph breaks
+ they typed survive. */}
+ {customMessage && customMessage.trim() !== "" && (
+
+
A note from the author
+
+ {customMessage}
+
+
+ )}
+
+ {onRespondAgain && (
+
+
+ Submit another response
+
+ )}
{/* ── what you sent ── */}
@@ -161,9 +197,7 @@ export function FormThankYou({
>
diff --git a/apps/web/components/landing/AnimatedCounter.tsx b/apps/web/components/landing/AnimatedCounter.tsx
index 78c0e8b..856cbef 100644
--- a/apps/web/components/landing/AnimatedCounter.tsx
+++ b/apps/web/components/landing/AnimatedCounter.tsx
@@ -1,11 +1,11 @@
"use client";
-import { useAnimatedCounter } from '~/hooks/useAnimatedCounter';
-import { useScrollReveal } from '~/hooks/useScrollReveal';
+import { useAnimatedCounter } from "~/hooks/useAnimatedCounter";
+import { useScrollReveal } from "~/hooks/useScrollReveal";
export function AnimatedCounter({
value,
- suffix = '',
+ suffix = "",
label,
}: {
value: number;
@@ -18,11 +18,12 @@ export function AnimatedCounter({
return (
- {count.toLocaleString()}{suffix}
+ {count.toLocaleString()}
+ {suffix}
{label}
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/components/landing/CanvasFlowMockup.tsx b/apps/web/components/landing/CanvasFlowMockup.tsx
index 70a10a8..919746e 100644
--- a/apps/web/components/landing/CanvasFlowMockup.tsx
+++ b/apps/web/components/landing/CanvasFlowMockup.tsx
@@ -1,16 +1,32 @@
"use client";
-import { useState, useRef, type CSSProperties } from 'react';
-import { motion, useInView } from 'motion/react';
+import { useState, useRef, type CSSProperties } from "react";
+import { motion, useInView } from "motion/react";
-export const GeoGlyph = ({ className = '' }: { className?: string }) => (
+export const GeoGlyph = ({ className = "" }: { className?: string }) => (
-
+
-
+
@@ -18,16 +34,19 @@ export const GeoGlyph = ({ className = '' }: { className?: string }) => (
);
const SERIES = [
- { name: 'Onboarding', color: 'var(--c-orange)', pts: [32, 36, 35, 41, 44, 42, 47] },
- { name: 'Pricing', color: 'var(--c-yellow)', pts: [28, 32, 30, 36, 39, 41, 40] },
- { name: 'Churn', color: 'var(--c-teal)', pts: [26, 28, 31, 30, 33, 35, 34] },
- { name: 'Signup', color: 'var(--c-blue)', pts: [22, 24, 27, 26, 28, 27, 30] },
- { name: 'Research', color: 'var(--c-purple)', pts: [16, 18, 17, 20, 19, 21, 22] },
- { name: 'Beta', color: 'var(--c-lavender)', pts: [9, 10, 11, 11, 12, 12, 13] },
+ { name: "Onboarding", color: "var(--c-orange)", pts: [32, 36, 35, 41, 44, 42, 47] },
+ { name: "Pricing", color: "var(--c-yellow)", pts: [28, 32, 30, 36, 39, 41, 40] },
+ { name: "Churn", color: "var(--c-teal)", pts: [26, 28, 31, 30, 33, 35, 34] },
+ { name: "Signup", color: "var(--c-blue)", pts: [22, 24, 27, 26, 28, 27, 30] },
+ { name: "Research", color: "var(--c-purple)", pts: [16, 18, 17, 20, 19, 21, 22] },
+ { name: "Beta", color: "var(--c-lavender)", pts: [9, 10, 11, 11, 12, 12, 13] },
];
const MultiLineChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
- const padL = 28, padR = 8, padT = 8, padB = 22;
+ const padL = 28,
+ padR = 8,
+ padT = 8,
+ padB = 22;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
const max = 50;
@@ -35,7 +54,7 @@ const MultiLineChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
const xs = basePts.map((_, i) => padL + (i * innerW) / (basePts.length - 1));
const yFor = (v: number) => padT + innerH - (v / max) * innerH;
const [hover, setHover] = useState
(null);
- const labels = ['Q1', '', '', 'Q2', '', '', 'Q3'];
+ const labels = ["Q1", "", "", "Q2", "", "", "Q3"];
const yTicks = [0, 10, 20, 30, 40, 50];
return (
@@ -43,36 +62,66 @@ const MultiLineChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
{yTicks.map((t) => (
- {t}
+
+ {t}
+
))}
{SERIES.map((s) => {
- const d = s.pts.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${yFor(v)}`).join(' ');
+ const d = s.pts.map((v, i) => `${i === 0 ? "M" : "L"} ${xs[i]} ${yFor(v)}`).join(" ");
const dim = hover && hover !== s.name;
return (
- setHover(s.name)}
- onMouseLeave={() => setHover(null)}
- style={{ transition: 'opacity .2s', opacity: dim ? 0.18 : 1, cursor: 'pointer' }}>
-
+ setHover(s.name)}
+ onMouseLeave={() => setHover(null)}
+ style={{ transition: "opacity .2s", opacity: dim ? 0.18 : 1, cursor: "pointer" }}
+ >
+
{s.pts.map((v, i) => (
))}
);
})}
- {labels.map((l, i) => l && (
- {l}
- ))}
+ {labels.map(
+ (l, i) =>
+ l && (
+
+ {l}
+
+ ),
+ )}
);
};
const SCATTER_GROUPS = [
- { color: 'var(--c-purple)', n: 36, cx: 0.18, cy: 0.55, spread: 0.12 },
- { color: 'var(--c-teal)', n: 42, cx: 0.45, cy: 0.40, spread: 0.16 },
- { color: 'var(--c-yellow)', n: 40, cx: 0.78, cy: 0.62, spread: 0.14 },
+ { color: "var(--c-purple)", n: 36, cx: 0.18, cy: 0.55, spread: 0.12 },
+ { color: "var(--c-teal)", n: 42, cx: 0.45, cy: 0.4, spread: 0.16 },
+ { color: "var(--c-yellow)", n: 40, cx: 0.78, cy: 0.62, spread: 0.14 },
];
/**
@@ -100,7 +149,10 @@ const round = (n: number, dp: number) => {
};
const ScatterChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
- const padL = 28, padR = 8, padT = 8, padB = 22;
+ const padL = 28,
+ padR = 8,
+ padT = 8,
+ padB = 22;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
const yTicks = [0, 5, 10, 15, 20, 25];
@@ -125,43 +177,89 @@ const ScatterChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
{yTicks.map((t) => (
-
- {t}
+
+
+ {t}
+
))}
{dots.map((d, i) => (
-
+
))}
{[0, 10, 20, 30, 40, 50].map((v, i) => (
- {v}
+
+ {v}
+
))}
);
};
const STACK_ROWS = [
- { label: 'Onboarding flow', parts: [42, 18, 24, 16] },
- { label: 'Pricing page', parts: [38, 22, 22, 18] },
- { label: 'Churn survey', parts: [30, 26, 28, 16] },
- { label: 'Dark mode poll', parts: [22, 28, 30, 20] },
+ { label: "Onboarding flow", parts: [42, 18, 24, 16] },
+ { label: "Pricing page", parts: [38, 22, 22, 18] },
+ { label: "Churn survey", parts: [30, 26, 28, 16] },
+ { label: "Dark mode poll", parts: [22, 28, 30, 20] },
];
-const STACK_COLORS = ['var(--c-red)', 'var(--c-yellow)', 'var(--c-teal)', 'var(--c-purple)'];
+const STACK_COLORS = ["var(--c-red)", "var(--c-yellow)", "var(--c-teal)", "var(--c-purple)"];
const StackedBars = () => (
{STACK_ROWS.map((r, ri) => {
const total = r.parts.reduce((a, b) => a + b, 0);
return (
-
-
{r.label}
-
+
+
+ {r.label}
+
+
{r.parts.map((p, i) => (
-
+
))}
@@ -171,26 +269,41 @@ const StackedBars = () => (
);
export const DashboardMock = () => {
- const [tab, setTab] = useState('Summary');
- const tabs = ['Summary', 'Responses', 'Drop-off', 'Segments'];
+ const [tab, setTab] = useState("Summary");
+ const tabs = ["Summary", "Responses", "Drop-off", "Segments"];
const ref = useRef(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });
return (
-
+
CanvasFlow · Onboarding Survey
- Edit
- Share
+
+ Edit
+
+
+ Share
+
-
Onboarding Survey · Q3 Overview
-
+
+ Onboarding Survey · Q3 Overview
+
+
Live breakdown of responses across product areas, segments, and time.
@@ -201,39 +314,55 @@ export const DashboardMock = () => {
style={{ borderBottomWidth: 1 }}
>
{tabs.map((t) => (
-
setTab(t)}>{t}
+
setTab(t)}
+ >
+ {t}
+
))}
{[
- { v: '1,284', l: 'Total responses', sub: '↑ 9.7% vs last week' },
- { v: '87%', l: 'Completion rate', sub: '↑ 4.1% vs last week' },
- { v: '2:14', l: 'Avg. time', sub: '↓ 0:18 vs last week' },
+ { v: "1,284", l: "Total responses", sub: "↑ 9.7% vs last week" },
+ { v: "87%", l: "Completion rate", sub: "↑ 4.1% vs last week" },
+ { v: "2:14", l: "Avg. time", sub: "↓ 0:18 vs last week" },
].map((k) => (
{k.v}
-
{k.l}
+
+ {k.l}
+
{/* The delta is the first thing to go: three of these side by
side at phone width leaves no room for a second line of
supporting text under each figure. */}
-
{k.sub}
+
+ {k.sub}
+
))}
{[
- { l: 'Form', v: 'All' },
- { l: 'Segment', v: 'All' },
- { l: 'Source', v: 'All' },
- { l: 'Quarter', v: 'Q3' },
+ { l: "Form", v: "All" },
+ { l: "Segment", v: "All" },
+ { l: "Source", v: "All" },
+ { l: "Quarter", v: "Q3" },
].map((f) => (
))}
@@ -244,22 +373,37 @@ export const DashboardMock = () => {
Responses by form · Q1–Q3
-
+
{SERIES.map((s) => (
-
- {s.name}
+
+
+ {s.name}
))}
Completion vs time-to-finish
-
+
- {['Mobile', 'Desktop', 'Embed'].map((g, i) => (
-
- {g}
+ {["Mobile", "Desktop", "Embed"].map((g, i) => (
+
+
+ {g}
))}
@@ -268,11 +412,16 @@ export const DashboardMock = () => {
Answers by question
-
+
- {['Loved it', 'Neutral', 'Friction', 'Confused'].map((g, i) => (
-
- {g}
+ {["Loved it", "Neutral", "Friction", "Confused"].map((g, i) => (
+
+
+ {g}
))}
@@ -286,19 +435,32 @@ export const FormBuilderMock = () => {
const [active, setActive] = useState(0);
return (
-
+
-
-
-
+
+
+
-
canvasflow.app/builder
+
+ canvasflow.app/builder
+
Draft
{/* Redundant next to "Draft" once space is tight. */}
- autosaved · 2s ago
+
+ autosaved · 2s ago
+
{/* The block palette is a 160px fixed column. Below sm it becomes a
@@ -307,12 +469,26 @@ export const FormBuilderMock = () => {
- Content Blocks
- {['Short text','Long text','Email','Phone','Number','Dropdown','Checkboxes','Rating'].map((b,i)=>(
- setActive(i)}
- className={`flex shrink-0 cursor-pointer items-center gap-2.5 rounded px-2.5 py-2 whitespace-nowrap transition-all ${active === i ? 'bg-black/5 text-black' : 'opacity-60'}`}>
+
+ Content Blocks
+
+ {[
+ "Short text",
+ "Long text",
+ "Email",
+ "Phone",
+ "Number",
+ "Dropdown",
+ "Checkboxes",
+ "Rating",
+ ].map((b, i) => (
+
setActive(i)}
+ className={`flex shrink-0 cursor-pointer items-center gap-2.5 rounded px-2.5 py-2 whitespace-nowrap transition-all ${active === i ? "bg-black/5 text-black" : "opacity-60"}`}
+ >
@@ -320,33 +496,54 @@ export const FormBuilderMock = () => {
))}
-
More
-
Toggle · Date · Time
+
+ More
+
+
+ Toggle · Date · Time
+
-
+
Q1 · Short text
-
What’s the biggest pain in your current workflow?
-
Type your answer…
+
+ What’s the biggest pain in your current workflow?
+
+
+ Type your answer…
+
-
+
-
+
Q2 · Multiple choice
-
How often does this friction occur?
+
+ How often does this friction occur?
+
- {['Every single day','A few times per week','Only during month-end'].map((opt,i)=>(
-
-
- {i===0 &&
}
+ {["Every single day", "A few times per week", "Only during month-end"].map(
+ (opt, i) => (
+
-
{opt}
-
- ))}
+ ),
+ )}
@@ -358,28 +555,28 @@ export const FormBuilderMock = () => {
/* ── Live responses feed ───────────────────────────────────────────── */
const FEED_ROWS = [
- { name: 'Aman R.', ago: 'just now', tag: 'Daily', c: 'var(--c-red)' },
- { name: 'Priya M.', ago: '12s ago', tag: 'Weekly', c: 'var(--c-yellow)' },
- { name: 'Jordan K.', ago: '48s ago', tag: 'Daily', c: 'var(--c-red)' },
- { name: 'Sara L.', ago: '1m ago', tag: 'Rarely', c: 'var(--c-teal)' },
- { name: 'Marc T.', ago: '2m ago', tag: 'Weekly', c: 'var(--c-yellow)' },
+ { name: "Aman R.", ago: "just now", tag: "Daily", c: "var(--c-red)" },
+ { name: "Priya M.", ago: "12s ago", tag: "Weekly", c: "var(--c-yellow)" },
+ { name: "Jordan K.", ago: "48s ago", tag: "Daily", c: "var(--c-red)" },
+ { name: "Sara L.", ago: "1m ago", tag: "Rarely", c: "var(--c-teal)" },
+ { name: "Marc T.", ago: "2m ago", tag: "Weekly", c: "var(--c-yellow)" },
];
export const ResponseFeedMock = () => (
Live responses
-
+
1,284
{/* Two stacked spans: the outer one pulses as a halo while the
inner dot stays solid, so the indicator reads as a signal
@@ -387,11 +584,11 @@ export const ResponseFeedMock = () => (
streaming
@@ -406,13 +603,13 @@ export const ResponseFeedMock = () => (
{...(i === 0
? // Newest row: faint tint plus a coloured edge, so the eye lands
// on the top of the feed where a live stream actually changes.
- { style: { background: 'rgba(58, 167, 147, 0.05)' } }
+ { style: { background: "rgba(58, 167, 147, 0.05)" } }
: {})}
>
{i === 0 && (
)}
@@ -420,16 +617,16 @@ export const ResponseFeedMock = () => (
{r.name[0]}
{r.name}
-
@@ -454,8 +651,8 @@ export const ResponseFeedMock = () => (
@@ -468,23 +665,40 @@ export const ResponseFeedMock = () => (
export const AnalyticsMock = DashboardMock;
export const CanvasEditorMock = () => {
- const [tab, setTab] = useState('Build');
- const tabs = ['Build', 'Fields', 'Share', 'Settings'];
+ const [tab, setTab] = useState("Build");
+ const tabs = ["Build", "Fields", "Share", "Settings"];
return (
-
+
- CanvasFlow · Customer Feedback Form
+
+ CanvasFlow · Customer Feedback Form
+
- Preview
- Publish
+
+ Preview
+
+
+ Publish
+
-
Form Builder · Interactive Canvas
-
+
+ Form Builder · Interactive Canvas
+
+
Drop in fields, drag to reorder, mark what’s required, then publish.
@@ -493,20 +707,36 @@ export const CanvasEditorMock = () => {
style={{ borderBottomWidth: 1 }}
>
{tabs.map((t) => (
-
setTab(t)}>{t}
+
setTab(t)}
+ >
+ {t}
+
))}
{[
- { v: '12', l: 'Fields', sub: 'Drag to reorder' },
- { v: '4', l: 'Required', sub: 'Validated on submit' },
- { v: '2m', l: 'Est. Time', sub: 'Average completion' },
+ { v: "12", l: "Fields", sub: "Drag to reorder" },
+ { v: "4", l: "Required", sub: "Validated on submit" },
+ { v: "2m", l: "Est. Time", sub: "Average completion" },
].map((k) => (
{k.v}
-
{k.l}
-
{k.sub}
+
+ {k.l}
+
+
+ {k.sub}
+
))}
@@ -517,68 +747,141 @@ export const CanvasEditorMock = () => {
{/* Tool rail: 56px of icons with no labels. Dropping it below sm
gives the canvas the room, and nothing it conveys is lost. */}
- {[1, 2, 3, 4, 5].map((i) => (
-
- {i === 1 &&
}
- {i === 2 &&
}
- {i === 3 &&
}
- {i === 4 &&
}
- {i === 5 &&
}
-
- ))}
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ {i === 1 && (
+
+
+
+ )}
+ {i === 2 && (
+
+
+
+
+ )}
+ {i === 3 && (
+
+
+
+ )}
+ {i === 4 && (
+
+
+
+ )}
+ {i === 5 && (
+
+
+
+
+
+
+ )}
+
+ ))}
-
-
+
-
- 01
-
- Rating · required
- How likely are you to recommend CanvasFlow?
-
- {[1, 2, 3, 4, 5].map(n => (
-
- {n}
-
- ))}
-
+
+ 01
+
+
+ Rating · required
+
+
+ How likely are you to recommend CanvasFlow?
+
+
+ {[1, 2, 3, 4, 5].map((n) => (
+
+ {n}
+
+ ))}
+
-
-
- 02
-
-
-
Long text · optional
-
-
- What is the primary reason for your score?
-
- Type your reason here...
-
+
+ 02
+
+
+
+ Long text · optional
+
+
+
+
+ What is the primary reason for your score?
+
+
+ Type your reason here...
+
-
+
@@ -586,41 +889,51 @@ export const CanvasEditorMock = () => {
rail and this panel together would leave the canvas narrower
than the field cards it is meant to be showing. */}
-
-
-
-
Component ID
-
q_reason_text
+
+
+
+
+ Component ID
-
-
-
Field settings
-
- {[
- { k: 'Type', v: 'Long text' },
- { k: 'Required', v: 'Off' },
- { k: 'Placeholder', v: 'Type your reason…' },
- ].map((row) => (
-
- {row.k}
- {row.v}
-
- ))}
-
+
+ q_reason_text
-
-
-
- Save field
-
-
+
+
+
+
+ Field settings
+
+
+ {[
+ { k: "Type", v: "Long text" },
+ { k: "Required", v: "Off" },
+ { k: "Placeholder", v: "Type your reason…" },
+ ].map((row) => (
+
+ {row.k}
+ {row.v}
+
+ ))}
+
+
+
+
+
+ Save field
+
+
-
);
-};
\ No newline at end of file
+};
diff --git a/apps/web/components/landing/IntellegentExecution.tsx b/apps/web/components/landing/IntellegentExecution.tsx
index 5c58990..6509778 100644
--- a/apps/web/components/landing/IntellegentExecution.tsx
+++ b/apps/web/components/landing/IntellegentExecution.tsx
@@ -1,4 +1,4 @@
-const Badge = ({ text, className = '' }: { text: string; className?: string }) => (
+const Badge = ({ text, className = "" }: { text: string; className?: string }) => (
@@ -11,7 +11,7 @@ export const IntelligentExecutionGraph = () => {
{
-
+
-
+
-
+
-
+
@@ -83,22 +99,28 @@ export const IntelligentExecutionGraph = () => {
-
+
Validation Check
- SCORE < {' '}
+ SCORE < {" "}
5
- AND SENTIMENT {' '}
+ AND SENTIMENT {" "}
is Negative
-
+
@@ -107,15 +129,18 @@ export const IntelligentExecutionGraph = () => {
generate follow_up_questions
from (
- context session_data){' '}
-
+ context session_data)
where sentiment = ((
negative
))
- using prompt_template({"'churn_risk'"} )
+ using prompt_template(
+ {"'churn_risk'"} )
-
+
@@ -124,7 +149,10 @@ export const IntelligentExecutionGraph = () => {
Very Dissatisfied
▼
-
+
@@ -158,4 +186,4 @@ export const IntelligentExecutionGraph = () => {
);
-};
\ No newline at end of file
+};
diff --git a/apps/web/components/landing/ScrollReveal.tsx b/apps/web/components/landing/ScrollReveal.tsx
index eb31f43..3631097 100644
--- a/apps/web/components/landing/ScrollReveal.tsx
+++ b/apps/web/components/landing/ScrollReveal.tsx
@@ -1,17 +1,17 @@
"use client";
-import React from 'react';
-import { useScrollReveal } from '~/hooks/useScrollReveal';
-import { cn } from '~/lib/utils';
+import React from "react";
+import { useScrollReveal } from "~/hooks/useScrollReveal";
+import { cn } from "~/lib/utils";
-type Direction = 'up' | 'down' | 'left' | 'right' | 'none';
+type Direction = "up" | "down" | "left" | "right" | "none";
const directionStyles: Record
= {
- up: 'translate-y-12',
- down: '-translate-y-12',
- left: 'translate-x-12',
- right: '-translate-x-12',
- none: '',
+ up: "translate-y-12",
+ down: "-translate-y-12",
+ left: "translate-x-12",
+ right: "-translate-x-12",
+ none: "",
};
interface ScrollRevealProps {
@@ -23,7 +23,7 @@ interface ScrollRevealProps {
export function ScrollReveal({
children,
- direction = 'up',
+ direction = "up",
delay = 0,
className,
}: ScrollRevealProps) {
@@ -33,13 +33,15 @@ export function ScrollReveal({
{children}
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/components/landing/TypeWritterText.tsx b/apps/web/components/landing/TypeWritterText.tsx
index d2a8ba0..707a8b7 100644
--- a/apps/web/components/landing/TypeWritterText.tsx
+++ b/apps/web/components/landing/TypeWritterText.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect } from 'react';
+import { useState, useEffect } from "react";
export function TypewriterText({
texts,
@@ -20,7 +20,7 @@ export function TypewriterText({
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
- const currentText = texts[textIndex] ?? '';
+ const currentText = texts[textIndex] ?? "";
if (!isDeleting && charIndex === currentText.length) {
const timeout = setTimeout(() => setIsDeleting(true), pauseDuration);
@@ -35,15 +35,15 @@ export function TypewriterText({
const timeout = setTimeout(
() => setCharIndex((prev) => prev + (isDeleting ? -1 : 1)),
- isDeleting ? deleteSpeed : speed
+ isDeleting ? deleteSpeed : speed,
);
return () => clearTimeout(timeout);
}, [charIndex, isDeleting, textIndex, texts, speed, deleteSpeed, pauseDuration]);
return (
- {(texts[textIndex] ?? '').substring(0, charIndex)}
+ {(texts[textIndex] ?? "").substring(0, charIndex)}
);
-}
\ No newline at end of file
+}
diff --git a/apps/web/hooks/api/analytics/index.ts b/apps/web/hooks/api/analytics/index.ts
index 0463281..2c9446a 100644
--- a/apps/web/hooks/api/analytics/index.ts
+++ b/apps/web/hooks/api/analytics/index.ts
@@ -1,7 +1,7 @@
import { trpc } from "~/trpc/client";
/**
- * Fetches all free-tier analytics metrics for a single form.
+ * Fetches all summary analytics metrics for a single form.
*
* Returns exactly: totalResponses, deviceBreakdown, dailyTrends (30d),
* peakDay, avgSubmissionsPerDay, avgSubmissionsPerWeek.
@@ -101,33 +101,23 @@ export const useRecordFieldAnswer = () => {
};
/**
- * Pro-tier: per-question distribution, day-of-week breakdown,
- * 30/60/90d trend totals, response velocity.
- * Only call this when you've already confirmed the user has Pro+/Business plan
- * via useGetMe — the server will still enforce it, but the client gates first.
+ * The heavier breakdown: per-question distribution, day-of-week, 30/60/90d
+ * trend totals, response velocity.
+ *
+ * Split from the summary query because it's the expensive one, not because it
+ * was ever gated — it's available to anyone who can see the form.
*/
-export const useGetProAnalytics = (formId: string) => {
+export const useGetDetailedAnalytics = (formId: string) => {
const {
- data: proAnalytics,
+ data: detailedAnalytics,
error,
isLoading,
isError,
refetch,
- } = trpc.analytics.getProAnalytics.useQuery(
+ } = trpc.analytics.getDetailedAnalytics.useQuery(
{ formId },
{ enabled: !!formId && formId.length === 36, retry: false },
);
- return { proAnalytics, error, isLoading, isError, refetch };
+ return { detailedAnalytics, error, isLoading, isError, refetch };
};
-
-/*
- * `useUserPlan` used to live here. It was a placeholder that always returned
- * `hasPro: false` while firing a full form-list query for nothing, so any
- * caller would have silently gated Pro features off for everyone.
- *
- * Use `useGetMe` from `~/hooks/api/user` instead — it exposes the real `plan`
- * and a derived `hasDetailedAnalytics`, which is what the analytics dashboard
- * already gates on. The server re-checks the plan in `proAuthenticatedProcedure`
- * regardless, so the client gate is a UX affordance, not the enforcement.
- */
diff --git a/apps/web/hooks/api/form/index.ts b/apps/web/hooks/api/form/index.ts
index 414ca0f..b83614c 100644
--- a/apps/web/hooks/api/form/index.ts
+++ b/apps/web/hooks/api/form/index.ts
@@ -128,6 +128,11 @@ export const useCreateFormField = () => {
const optimistic = {
id: `optimistic-${Date.now()}`,
formId: input.formId,
+ // Carried through from the request rather than defaulted. Dropping it
+ // put the optimistic row outside every segment, so a form filtered to
+ // one segment showed nothing appear until the server reply landed and
+ // the cache was replaced.
+ segmentId: input.segmentId ?? null,
label: input.label ?? "",
labelKey: input.label?.toLowerCase().replace(/[^a-z0-9]+/g, "-") ?? "field",
placeholder: input.placeholder ?? null,
@@ -575,3 +580,289 @@ export const useUpdateFormSettings = () => {
error,
};
};
+
+/* ─── Segments ────────────────────────────────────────────────────────── */
+
+export const useListFormSegments = (formId: string) => {
+ const {
+ data: segments,
+ error,
+ failureCount,
+ isError,
+ isSuccess,
+ status,
+ isLoading,
+ refetch,
+ } = trpc.form.listFormSegments.useQuery(
+ { formId },
+ { enabled: !!formId && formId.length === 36 },
+ );
+
+ return {
+ segments,
+ error,
+ isError,
+ isSuccess,
+ status,
+ failureCount,
+ isLoading,
+ refetch,
+ };
+};
+
+export const useCreateFormSegment = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: createFormSegmentAsync,
+ mutate: createFormSegment,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ } = trpc.form.createFormSegment.useMutation({
+ // No optimistic update here, unlike createFormField. The first segment
+ // on a form also materialises "Segment 1" and re-parents every existing
+ // question server-side; the client can't predict those ids, so guessing
+ // would paint a segment list that has to be thrown away a moment later.
+ onSettled: (_data, _err, input) => {
+ void utils.form.listFormSegments.invalidate({ formId: input.formId });
+ // Fields move into the default segment as part of this call.
+ void utils.form.listFormFields.invalidate({ formId: input.formId });
+ },
+ });
+
+ return {
+ createFormSegmentAsync,
+ createFormSegment,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ };
+};
+
+export const useUpdateFormSegment = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: updateFormSegmentAsync,
+ mutate: updateFormSegment,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ } = trpc.form.updateFormSegment.useMutation({
+ // Input carries only the segment id, so scope-by-formId isn't available
+ // — same trade-off the field mutations already make.
+ onSettled: () => {
+ void utils.form.listFormSegments.invalidate();
+ },
+ });
+
+ return {
+ updateFormSegmentAsync,
+ updateFormSegment,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ };
+};
+
+export const useDeleteFormSegment = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: deleteFormSegmentAsync,
+ mutate: deleteFormSegment,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ } = trpc.form.deleteFormSegment.useMutation({
+ onSettled: () => {
+ void utils.form.listFormSegments.invalidate();
+ // Questions in the deleted segment fall back to the implicit first
+ // segment, and rules targeting it are cascade-deleted.
+ void utils.form.listFormFields.invalidate();
+ void utils.form.listLogicRules.invalidate();
+ },
+ });
+
+ return {
+ deleteFormSegmentAsync,
+ deleteFormSegment,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ };
+};
+
+/* ─── Conditional branching ───────────────────────────────────────────── */
+
+export const useListLogicRules = (formId: string) => {
+ const {
+ data: logicRules,
+ error,
+ failureCount,
+ isError,
+ isSuccess,
+ status,
+ isLoading,
+ refetch,
+ } = trpc.form.listLogicRules.useQuery({ formId }, { enabled: !!formId && formId.length === 36 });
+
+ return {
+ logicRules,
+ error,
+ isError,
+ isSuccess,
+ status,
+ failureCount,
+ isLoading,
+ refetch,
+ };
+};
+
+export const useCreateLogicRule = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: createLogicRuleAsync,
+ mutate: createLogicRule,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ } = trpc.form.createLogicRule.useMutation({
+ onSettled: (_data, _err, input) => {
+ void utils.form.listLogicRules.invalidate({ formId: input.formId });
+ },
+ });
+
+ return {
+ createLogicRuleAsync,
+ createLogicRule,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ };
+};
+
+export const useUpdateLogicRule = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: updateLogicRuleAsync,
+ mutate: updateLogicRule,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ } = trpc.form.updateLogicRule.useMutation({
+ onSettled: () => {
+ void utils.form.listLogicRules.invalidate();
+ },
+ });
+
+ return {
+ updateLogicRuleAsync,
+ updateLogicRule,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ };
+};
+
+export const useDeleteLogicRule = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: deleteLogicRuleAsync,
+ mutate: deleteLogicRule,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ } = trpc.form.deleteLogicRule.useMutation({
+ onSettled: () => {
+ void utils.form.listLogicRules.invalidate();
+ },
+ });
+
+ return {
+ deleteLogicRuleAsync,
+ deleteLogicRule,
+ error,
+ isPending,
+ isSuccess,
+ status,
+ };
+};
+
+/* ─── Drafts ──────────────────────────────────────────────────────────── */
+
+/**
+ * The signed-in respondent's saved progress on a form, or null.
+ *
+ * `enabled` gates on the caller telling us they're signed in: the procedure is
+ * authenticated, so calling it anonymously is a guaranteed 401 on every public
+ * form load. `retry: false` for the same reason — a failure here means "no
+ * draft", never something worth retrying.
+ *
+ * `staleTime: Infinity` because the draft is only read once, to restore. After
+ * that the renderer's own state is the truth, and a refetch that overwrote it
+ * mid-typing would be a bug rather than a refresh.
+ */
+export const useGetDraft = (formId: string, enabled: boolean) => {
+ const {
+ data: draft,
+ isLoading,
+ isFetched,
+ } = trpc.form.getDraft.useQuery(
+ { formId },
+ {
+ enabled: enabled && !!formId && formId.length === 36,
+ retry: false,
+ staleTime: Infinity,
+ refetchOnWindowFocus: false,
+ },
+ );
+
+ return { draft, isLoading, isFetched };
+};
+
+export const useSaveDraft = () => {
+ const {
+ mutateAsync: saveDraftAsync,
+ mutate: saveDraft,
+ isPending,
+ error,
+ } = trpc.form.saveDraft.useMutation({
+ // Deliberately no invalidation. This fires repeatedly while someone types,
+ // and refetching the draft we just wrote would pull the server's copy back
+ // over the state that produced it.
+ });
+
+ return { saveDraftAsync, saveDraft, isPending, error };
+};
+
+export const useDeleteDraft = () => {
+ const utils = trpc.useUtils();
+
+ const {
+ mutateAsync: deleteDraftAsync,
+ mutate: deleteDraft,
+ isPending,
+ } = trpc.form.deleteDraft.useMutation({
+ onSettled: (_data, _err, input) => {
+ void utils.form.getDraft.invalidate({ formId: input.formId });
+ },
+ });
+
+ return { deleteDraftAsync, deleteDraft, isPending };
+};
diff --git a/apps/web/hooks/api/user/index.ts b/apps/web/hooks/api/user/index.ts
index cabda07..83570bb 100644
--- a/apps/web/hooks/api/user/index.ts
+++ b/apps/web/hooks/api/user/index.ts
@@ -1,24 +1,14 @@
import { trpc } from "~/trpc/client";
-export type UserPlan = "Free" | "Pro" | "Pro+" | "Business";
-
-/**
- * Fetches the current user's profile including their subscription plan.
- * Use this to gate Pro+/Business features on the frontend.
- */
+/** Fetches the current user's profile. */
export const useGetMe = () => {
const { data, isLoading, error } = trpc.user.getMe.useQuery(undefined, {
- staleTime: 5 * 60 * 1000, // 5 min — plan rarely changes
+ staleTime: 5 * 60 * 1000,
retry: false,
});
- const plan: UserPlan = data?.plan ?? "Free";
- const hasDetailedAnalytics = plan === "Pro+" || plan === "Business";
-
return {
me: data,
- plan,
- hasDetailedAnalytics,
isLoading,
error,
};
diff --git a/apps/web/hooks/use-toasts.ts b/apps/web/hooks/use-toasts.ts
index 80ad1fc..37bd2c4 100644
--- a/apps/web/hooks/use-toasts.ts
+++ b/apps/web/hooks/use-toasts.ts
@@ -194,4 +194,4 @@ function useToast() {
};
}
-export { useToast, toast };
\ No newline at end of file
+export { useToast, toast };
diff --git a/apps/web/hooks/useAnimatedCounter.ts b/apps/web/hooks/useAnimatedCounter.ts
index 0a3d89a..17cd306 100644
--- a/apps/web/hooks/useAnimatedCounter.ts
+++ b/apps/web/hooks/useAnimatedCounter.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useState } from "react";
export function useAnimatedCounter(target: number, duration = 1500, start = false) {
const [count, setCount] = useState(0);
@@ -20,4 +20,4 @@ export function useAnimatedCounter(target: number, duration = 1500, start = fals
}, [target, duration, start]);
return count;
-}
\ No newline at end of file
+}
diff --git a/apps/web/hooks/useDebouncedCallback.ts b/apps/web/hooks/useDebouncedCallback.ts
new file mode 100644
index 0000000..28561e4
--- /dev/null
+++ b/apps/web/hooks/useDebouncedCallback.ts
@@ -0,0 +1,83 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+
+/**
+ * Defer a call until the caller stops calling it for `delay` ms.
+ *
+ * Used for the respondent's autosave and answer tracking, where the alternative
+ * is a request per keystroke. The two properties that matter here beyond plain
+ * debouncing:
+ *
+ * · The latest arguments win. A pending call is replaced, not queued, so the
+ * request that eventually goes out carries the newest answers rather than
+ * whatever was typed 800ms ago.
+ * · `flush()` fires the pending call immediately. Without it, closing the tab
+ * or hitting Submit mid-debounce would silently drop the last edit — exactly
+ * the edit the respondent most expects to have been saved.
+ *
+ * The callback is held in a ref so a fresh closure each render doesn't restart
+ * the timer; a debounce that resets whenever React re-renders never fires while
+ * someone is actively typing.
+ */
+export function useDebouncedCallback(
+ callback: (...args: Args) => void,
+ delay: number,
+): {
+ run: (...args: Args) => void;
+ flush: () => void;
+ cancel: () => void;
+} {
+ const callbackRef = useRef(callback);
+ const timerRef = useRef | null>(null);
+ const pendingArgsRef = useRef(null);
+
+ useEffect(() => {
+ callbackRef.current = callback;
+ }, [callback]);
+
+ const clear = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ const run = useCallback(
+ (...args: Args) => {
+ pendingArgsRef.current = args;
+ clear();
+ timerRef.current = setTimeout(() => {
+ timerRef.current = null;
+ const pending = pendingArgsRef.current;
+ pendingArgsRef.current = null;
+ if (pending) callbackRef.current(...pending);
+ }, delay);
+ },
+ [clear, delay],
+ );
+
+ const flush = useCallback(() => {
+ const pending = pendingArgsRef.current;
+ clear();
+ pendingArgsRef.current = null;
+ if (pending) callbackRef.current(...pending);
+ }, [clear]);
+
+ const cancel = useCallback(() => {
+ clear();
+ pendingArgsRef.current = null;
+ }, [clear]);
+
+ // Fire whatever is pending on unmount rather than dropping it. Navigating
+ // away mid-debounce is the common way to lose the last answer.
+ useEffect(() => {
+ return () => {
+ const pending = pendingArgsRef.current;
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ if (pending) callbackRef.current(...pending);
+ };
+ }, []);
+
+ return { run, flush, cancel };
+}
diff --git a/apps/web/hooks/useScrollReveal.ts b/apps/web/hooks/useScrollReveal.ts
index 1994608..88b525d 100644
--- a/apps/web/hooks/useScrollReveal.ts
+++ b/apps/web/hooks/useScrollReveal.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from "react";
export function useScrollReveal(threshold = 0.15) {
const ref = useRef(null);
@@ -8,12 +8,17 @@ export function useScrollReveal(threshold = 0.15) {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
- ([entry]) => { if (entry?.isIntersecting) { setIsVisible(true); observer.unobserve(el); } },
- { threshold }
+ ([entry]) => {
+ if (entry?.isIntersecting) {
+ setIsVisible(true);
+ observer.unobserve(el);
+ }
+ },
+ { threshold },
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return { ref, isVisible };
-}
\ No newline at end of file
+}
diff --git a/apps/web/lib/form-access.ts b/apps/web/lib/form-access.ts
new file mode 100644
index 0000000..1ed968d
--- /dev/null
+++ b/apps/web/lib/form-access.ts
@@ -0,0 +1,91 @@
+/**
+ * Who is allowed to answer a form — the respondent's side of it.
+ *
+ * A deliberate mirror of `packages/services/form-submission/access.ts`, which
+ * remains the enforcing copy. This exists because the public form has to answer
+ * a question *before* anyone submits: "should this person be shown the form, or
+ * a sign-in wall, or a wrong-domain notice?" Asking somebody fifty questions
+ * and only then telling them to sign in wastes their time and loses their
+ * answers.
+ *
+ * It's duplicated rather than imported because `apps/web` depends on
+ * `@repo/trpc` alone; the services package is server-side and pulls in the
+ * database client, which has no business in a browser bundle. Same reasoning
+ * and same shape as `form-flow.ts` and `form-logic.ts`.
+ *
+ * Only the pure predicates live here. `assertRespondentAllowed` stays on the
+ * server, because a gate the client can compute is a hint, not a rule — these
+ * functions decide what to *show*, never what to *allow*. If the two ever
+ * disagree the server wins and the respondent sees the matching error screen.
+ */
+
+/** The access-controlling settings, as stored on the form. */
+export interface FormAccessRules {
+ requireSignIn: boolean;
+ collectRespondentEmail: boolean;
+ oneResponsePerRespondent: boolean;
+ allowedEmailDomains?: string[] | null;
+}
+
+/**
+ * Does the form need a signed-in respondent?
+ *
+ * The three dependent settings each imply it. Recording an email, holding
+ * someone to one response, and restricting by domain are all impossible without
+ * an account to read, so any of them turns the requirement on whether or not
+ * the author ticked it.
+ */
+export function requiresSignIn(rules: FormAccessRules): boolean {
+ return (
+ rules.requireSignIn ||
+ rules.collectRespondentEmail ||
+ rules.oneResponsePerRespondent ||
+ (rules.allowedEmailDomains?.length ?? 0) > 0
+ );
+}
+
+/** `dittya@nst.rishihood.edu.in` -> `nst.rishihood.edu.in` */
+export function emailDomain(email: string): string {
+ const at = email.lastIndexOf("@");
+ return at === -1 ? "" : email.slice(at + 1).trim().toLowerCase();
+}
+
+/**
+ * Normalise an author-supplied domain. Accepts what people actually type —
+ * `@example.com`, `https://example.com`, `Example.COM ` — because rejecting
+ * those would just mean a restriction that silently matches nothing.
+ */
+export function normaliseDomain(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, "")
+ .replace(/^@/, "")
+ .replace(/\/.*$/, "")
+ .replace(/^\.+|\.+$/g, "");
+}
+
+/**
+ * Is this email inside one of the allowed domains?
+ *
+ * Suffix matching, deliberately: an entry of `rishihood.edu.in` admits both
+ * `kamlesh@rishihood.edu.in` and `dittya@nst.rishihood.edu.in`, because a
+ * university that hands out per-department subdomains is still one institution
+ * and the author should not have to enumerate them.
+ *
+ * The boundary check is what keeps that from being too loose. Matching on a
+ * bare `endsWith` would also admit `evil-rishihood.edu.in`, so a longer domain
+ * only matches when the extra part ends at a dot.
+ */
+export function isEmailDomainAllowed(email: string, allowed?: string[] | null): boolean {
+ if (!allowed || allowed.length === 0) return true;
+
+ const domain = emailDomain(email);
+ if (!domain) return false;
+
+ return allowed.some((entry) => {
+ const candidate = normaliseDomain(entry);
+ if (!candidate) return false;
+ return domain === candidate || domain.endsWith(`.${candidate}`);
+ });
+}
diff --git a/apps/web/lib/form-flow.ts b/apps/web/lib/form-flow.ts
new file mode 100644
index 0000000..4f80e3e
--- /dev/null
+++ b/apps/web/lib/form-flow.ts
@@ -0,0 +1,801 @@
+/**
+ * Form traversal: which question comes next.
+ *
+ * Without branching this was `currentQuestionIndex + 1`. With segments and
+ * conditional rules the order is no longer a property of the form alone — it
+ * depends on what the respondent has answered so far. Everything here is pure
+ * so the renderer stays a thin shell over it, the builder can describe and
+ * lint rules with the same code the renderer obeys, and the whole thing can be
+ * exercised without a browser.
+ *
+ * Three ideas carry the module:
+ *
+ * 1. A form is a flat list of questions ordered by (segment, question).
+ * Segments are groups, not a second traversal layer — "jump to segment 3"
+ * is really "jump to the first question of segment 3", which keeps one
+ * code path instead of two.
+ *
+ * 2. A rule is an if/else attached to the question that triggers it. Its
+ * conditions can read *any* question's answer, combined with ALL or ANY.
+ * When they hold it takes its `action`; when they don't it takes
+ * `elseAction` if it has one, and otherwise stands aside for the next rule.
+ *
+ * 3. Rules are checked in `index` order and the first decision wins. No
+ * decision means fall through to the next question in the flat list, which
+ * is what makes an un-branched form behave exactly as it did before.
+ */
+
+import { parseIndex } from "./fractional-index";
+
+/* ─── Shapes ──────────────────────────────────────────────────────────────
+ *
+ * Structural types rather than imports from the server models: this module
+ * only needs the properties it actually reads, and staying structural means
+ * the builder's in-progress local rows (temp ids, no timestamps) satisfy it
+ * too.
+ */
+
+export interface FlowField {
+ id: string;
+ segmentId?: string | null;
+ label: string;
+ type: string;
+ isRequired: boolean;
+ index: string | number;
+ options?: unknown;
+}
+
+export interface FlowSegment {
+ id: string;
+ title: string;
+ description?: string | null;
+ index: string | number;
+}
+
+export type LogicOperator =
+ | "EQUALS"
+ | "NOT_EQUALS"
+ | "CONTAINS"
+ | "NOT_CONTAINS"
+ | "GREATER_THAN"
+ | "LESS_THAN"
+ | "IS_EMPTY"
+ | "IS_NOT_EMPTY"
+ | "IS_ANY_OF"
+ | "IS_NONE_OF"
+ | "STARTS_WITH"
+ | "ENDS_WITH";
+
+export type LogicMatch = "ALL" | "ANY";
+
+export type LogicAction = "JUMP_TO_FIELD" | "JUMP_TO_SEGMENT" | "SUBMIT" | "CONTINUE";
+
+/** Operators that test presence and take no operand. */
+export const VALUELESS_OPERATORS: readonly LogicOperator[] = ["IS_EMPTY", "IS_NOT_EMPTY"];
+
+/** Operators whose operand is a list of choices. */
+export const MULTI_VALUE_OPERATORS: readonly LogicOperator[] = ["IS_ANY_OF", "IS_NONE_OF"];
+
+/** Actions that carry no destination. */
+export const TARGETLESS_ACTIONS: readonly LogicAction[] = ["SUBMIT", "CONTINUE"];
+
+export interface FlowCondition {
+ id: string;
+ /** The question whose answer this condition reads. Any question in the form. */
+ fieldId: string;
+ operator: LogicOperator;
+ value?: unknown;
+ index: string | number;
+}
+
+export interface FlowRule {
+ id: string;
+ /** The question after which this rule is evaluated. */
+ fieldId: string;
+ match: LogicMatch;
+ conditions: FlowCondition[];
+
+ action: LogicAction;
+ targetFieldId?: string | null;
+ targetSegmentId?: string | null;
+
+ elseAction?: LogicAction | null;
+ elseTargetFieldId?: string | null;
+ elseTargetSegmentId?: string | null;
+
+ index: string | number;
+}
+
+export type Answers = Record;
+
+/** Where the respondent goes next. `end` covers both "ran out of questions"
+ * and "a branch said finish here". */
+export type NextStep = { kind: "field"; fieldId: string } | { kind: "end" };
+
+/** A form flattened into traversal order, with the lookups the resolver needs.
+ * Build once per form and pass it around. */
+export interface Flow {
+ /** Every question in traversal order. */
+ order: FlowField[];
+ /** Position of each question id within `order`. */
+ positionById: Map;
+ fieldById: Map;
+ segmentById: Map;
+ /** Segments in display order. Excludes the implicit segment. */
+ segments: FlowSegment[];
+ /** Rules grouped by their trigger question, each list in `index` order. */
+ rulesByFieldId: Map;
+}
+
+/* ─── Ordering ────────────────────────────────────────────────────────── */
+
+function byIndex(a: T, b: T): number {
+ const d = parseIndex(a.index) - parseIndex(b.index);
+ if (d !== 0 && Number.isFinite(d)) return d;
+ // Stable tiebreak. Two rows can share an index while a reorder is mid-save,
+ // and an unstable sort there would make questions visibly swap.
+ return a.id.localeCompare(b.id);
+}
+
+/**
+ * Flatten a form into traversal order.
+ *
+ * Questions with no `segmentId` come first. They exist only on forms that were
+ * never segmented (where they are the whole form) or, briefly, on a form whose
+ * last segment was just deleted — in both cases "before every real segment" is
+ * the intuitive place for them.
+ */
+export function buildFlow(
+ fields: readonly FlowField[],
+ segments: readonly FlowSegment[] = [],
+ rules: readonly FlowRule[] = [],
+): Flow {
+ const orderedSegments = [...segments].sort(byIndex);
+
+ const unassigned: FlowField[] = [];
+ const bySegment = new Map();
+ for (const segment of orderedSegments) bySegment.set(segment.id, []);
+
+ for (const field of fields) {
+ const bucket = field.segmentId ? bySegment.get(field.segmentId) : undefined;
+ if (bucket) {
+ bucket.push(field);
+ } else {
+ // Either genuinely unassigned, or assigned to a segment absent from the
+ // list we were handed. Treating an unknown segment as unassigned keeps
+ // the question reachable instead of silently dropping it.
+ unassigned.push(field);
+ }
+ }
+
+ const order: FlowField[] = [...unassigned].sort(byIndex);
+ for (const segment of orderedSegments) {
+ order.push(...(bySegment.get(segment.id) ?? []).sort(byIndex));
+ }
+
+ const positionById = new Map();
+ order.forEach((field, i) => positionById.set(field.id, i));
+
+ const rulesByFieldId = new Map();
+ for (const rule of rules) {
+ const list = rulesByFieldId.get(rule.fieldId);
+ if (list) list.push(rule);
+ else rulesByFieldId.set(rule.fieldId, [rule]);
+ }
+ for (const list of rulesByFieldId.values()) list.sort(byIndex);
+
+ return {
+ order,
+ positionById,
+ fieldById: new Map(order.map((f) => [f.id, f])),
+ segmentById: new Map(orderedSegments.map((s) => [s.id, s])),
+ segments: orderedSegments,
+ rulesByFieldId,
+ };
+}
+
+/* ─── Condition matching ──────────────────────────────────────────────── */
+
+/**
+ * Whether an answer counts as "not given".
+ *
+ * `false` is deliberately NOT empty: an unchecked toggle is an answer, and
+ * treating it as missing would make IS_EMPTY fire on a question the respondent
+ * did answer. `0` is an answer for the same reason.
+ */
+export function isAnswerEmpty(answer: unknown): boolean {
+ if (answer === undefined || answer === null) return true;
+ if (typeof answer === "string") return answer.trim() === "";
+ if (Array.isArray(answer)) return answer.length === 0;
+ return false;
+}
+
+function toBoolean(v: unknown): boolean {
+ if (typeof v === "boolean") return v;
+ const s = String(v).trim().toLowerCase();
+ return s === "true" || s === "yes" || s === "1";
+}
+
+/** Case- and whitespace-insensitive scalar comparison. Choice labels are free
+ * text typed twice — once on the question, once on the condition — so exact
+ * matching would fail on invisible differences. */
+function scalarEquals(a: unknown, b: unknown): boolean {
+ if (typeof a === "boolean" || typeof b === "boolean") return toBoolean(a) === toBoolean(b);
+ return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
+}
+
+function normalise(v: unknown): string {
+ return String(v).trim().toLowerCase();
+}
+
+/** Numeric comparison that also understands dates, so GREATER_THAN works on
+ * DATE/DATETIME answers. Returns null when either side isn't comparable —
+ * callers treat that as "no match" rather than guessing an ordering. */
+function compareOrdered(answer: unknown, value: unknown): number | null {
+ const a = Number(answer);
+ const b = Number(value);
+ if (Number.isFinite(a) && Number.isFinite(b)) return a - b;
+
+ const da = Date.parse(String(answer));
+ const dbv = Date.parse(String(value));
+ if (Number.isFinite(da) && Number.isFinite(dbv)) return da - dbv;
+
+ return null;
+}
+
+/** The operand as a list, for IS_ANY_OF / IS_NONE_OF. Tolerates a single
+ * scalar so a rule authored before the operator changed still behaves. */
+function asList(value: unknown): unknown[] {
+ if (Array.isArray(value)) return value;
+ if (value === undefined || value === null || value === "") return [];
+ return [value];
+}
+
+/**
+ * Does one condition hold for the given answer?
+ *
+ * Multi-select answers arrive as arrays, which is why EQUALS has an
+ * array-specific reading: "is X" on a checkbox question means X is the only
+ * thing selected. An author who means "X is among the selections" reaches for
+ * `contains`, and the two stay distinguishable.
+ */
+export function matchesCondition(condition: FlowCondition, answer: unknown): boolean {
+ const empty = isAnswerEmpty(answer);
+
+ switch (condition.operator) {
+ case "IS_EMPTY":
+ return empty;
+ case "IS_NOT_EMPTY":
+ return !empty;
+ default:
+ break;
+ }
+
+ // Every remaining operator compares against something. An unanswered
+ // question matches none of them — except the negative ones, where "no
+ // answer" genuinely is "not equal to X".
+ if (empty) {
+ return (
+ condition.operator === "NOT_EQUALS" ||
+ condition.operator === "NOT_CONTAINS" ||
+ condition.operator === "IS_NONE_OF"
+ );
+ }
+
+ const { operator, value } = condition;
+
+ switch (operator) {
+ case "EQUALS":
+ return Array.isArray(answer)
+ ? answer.length === 1 && scalarEquals(answer[0], value)
+ : scalarEquals(answer, value);
+
+ case "NOT_EQUALS":
+ return Array.isArray(answer)
+ ? !(answer.length === 1 && scalarEquals(answer[0], value))
+ : !scalarEquals(answer, value);
+
+ case "CONTAINS":
+ return Array.isArray(answer)
+ ? answer.some((entry) => scalarEquals(entry, value))
+ : normalise(answer).includes(normalise(value));
+
+ case "NOT_CONTAINS":
+ return Array.isArray(answer)
+ ? !answer.some((entry) => scalarEquals(entry, value))
+ : !normalise(answer).includes(normalise(value));
+
+ case "IS_ANY_OF": {
+ const list = asList(value);
+ return Array.isArray(answer)
+ ? answer.some((entry) => list.some((v) => scalarEquals(entry, v)))
+ : list.some((v) => scalarEquals(answer, v));
+ }
+
+ case "IS_NONE_OF": {
+ const list = asList(value);
+ return Array.isArray(answer)
+ ? !answer.some((entry) => list.some((v) => scalarEquals(entry, v)))
+ : !list.some((v) => scalarEquals(answer, v));
+ }
+
+ case "STARTS_WITH":
+ return normalise(Array.isArray(answer) ? answer[0] : answer).startsWith(normalise(value));
+
+ case "ENDS_WITH":
+ return normalise(Array.isArray(answer) ? answer[0] : answer).endsWith(normalise(value));
+
+ case "GREATER_THAN": {
+ const cmp = compareOrdered(answer, value);
+ return cmp !== null && cmp > 0;
+ }
+
+ case "LESS_THAN": {
+ const cmp = compareOrdered(answer, value);
+ return cmp !== null && cmp < 0;
+ }
+
+ default:
+ // Unknown operator — an older client meeting a newer rule. Not matching
+ // is the safe reading: the respondent continues linearly rather than
+ // being sent somewhere arbitrary.
+ return false;
+ }
+}
+
+/**
+ * Does a rule's condition set hold?
+ *
+ * A rule with no conditions returns false, never true. It's an incomplete rule
+ * the author hasn't finished, and the safe reading of "no tests" is "does not
+ * apply" — treating it as vacuously true would silently turn it into an
+ * unconditional jump and reroute the whole form.
+ */
+export function ruleMatches(rule: FlowRule, answers: Answers): boolean {
+ if (rule.conditions.length === 0) return false;
+
+ const results = rule.conditions.map((condition) =>
+ matchesCondition(condition, answers[condition.fieldId]),
+ );
+
+ return rule.match === "ANY" ? results.some(Boolean) : results.every(Boolean);
+}
+
+/* ─── Resolution ──────────────────────────────────────────────────────── */
+
+/** The first question of a segment, skipping empty segments so a jump to a
+ * segment with no questions yet lands on the next real one instead of
+ * dead-ending. */
+function firstFieldOfSegment(flow: Flow, segmentId: string): string | null {
+ const start = flow.segments.findIndex((s) => s.id === segmentId);
+ if (start === -1) return null;
+
+ for (let i = start; i < flow.segments.length; i++) {
+ const segment = flow.segments[i];
+ if (!segment) continue;
+ const field = flow.order.find((f) => f.segmentId === segment.id);
+ if (field) return field.id;
+ }
+ return null;
+}
+
+/** The next question in plain document order. */
+function linearNext(flow: Flow, fromFieldId: string): string | null {
+ const position = flow.positionById.get(fromFieldId);
+ if (position === undefined) return null;
+ return flow.order[position + 1]?.id ?? null;
+}
+
+/**
+ * Where does the respondent go after answering `fromFieldId`?
+ *
+ * `visited` is the path taken so far and exists to break cycles. Rules can
+ * describe a loop no single-row constraint can catch (A jumps to B, B jumps
+ * back to A); the database rejects the one-hop case and this rejects the rest.
+ * Landing on an already-visited question ends the form rather than trapping
+ * the respondent — a wrong ending is recoverable, an infinite loop is not.
+ */
+export function resolveNextStep(args: {
+ flow: Flow;
+ answers: Answers;
+ fromFieldId: string;
+ visited?: readonly string[];
+}): NextStep {
+ const { flow, answers, fromFieldId, visited = [] } = args;
+
+ const decision = resolveRuleDecision({ flow, answers, fromFieldId, visited });
+ if (decision) return decision;
+
+ return guardStep(flow, linearNext(flow, fromFieldId), visited);
+}
+
+/** Shared by the question-level and page-level walks so there is exactly one
+ * implementation of "is this destination usable". */
+function guardStep(
+ flow: Flow,
+ fieldId: string | null | undefined,
+ visited: readonly string[],
+): NextStep {
+ if (!fieldId) return { kind: "end" };
+ if (visited.includes(fieldId)) return { kind: "end" };
+ if (!flow.fieldById.has(fieldId)) return { kind: "end" };
+ return { kind: "field", fieldId };
+}
+
+/**
+ * The decision a question's rules reach, or `null` when none of them decides.
+ *
+ * Split out from `resolveNextStep` because the page-level walk needs to tell
+ * "a rule sent them somewhere" apart from "nothing matched, carry on" — the
+ * two are the same outcome for one question at a time, but on a multi-question
+ * page only the first is a reason to leave. Keeping it as one function means
+ * the two layouts can't drift on which rule wins.
+ */
+export function resolveRuleDecision(args: {
+ flow: Flow;
+ answers: Answers;
+ fromFieldId: string;
+ visited?: readonly string[];
+}): NextStep | null {
+ const { flow, answers, fromFieldId, visited = [] } = args;
+
+ /** Turn one side of a rule into a step. `null` means the side declined to
+ * decide, so the next rule gets a turn. */
+ const applySide = (
+ action: LogicAction | null | undefined,
+ targetFieldId: string | null | undefined,
+ targetSegmentId: string | null | undefined,
+ ): NextStep | null => {
+ switch (action) {
+ case "SUBMIT":
+ return { kind: "end" };
+ case "CONTINUE":
+ return guardStep(flow, linearNext(flow, fromFieldId), visited);
+ case "JUMP_TO_FIELD":
+ return guardStep(flow, targetFieldId, visited);
+ case "JUMP_TO_SEGMENT":
+ return guardStep(
+ flow,
+ targetSegmentId ? firstFieldOfSegment(flow, targetSegmentId) : null,
+ visited,
+ );
+ default:
+ // No action set, or one from a newer schema this client doesn't know.
+ return null;
+ }
+ };
+
+ for (const rule of flow.rulesByFieldId.get(fromFieldId) ?? []) {
+ if (ruleMatches(rule, answers)) {
+ const step = applySide(rule.action, rule.targetFieldId, rule.targetSegmentId);
+ if (step) return step;
+ continue;
+ }
+
+ // Conditions failed. A rule with an otherwise-branch is a fork and decides
+ // here; a rule without one is a guard clause and stands aside.
+ if (rule.elseAction) {
+ const step = applySide(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId);
+ if (step) return step;
+ }
+ }
+
+ return null;
+}
+
+/** The question a respondent starts on. */
+export function resolveFirstStep(flow: Flow): NextStep {
+ const first = flow.order[0];
+ return first ? { kind: "field", fieldId: first.id } : { kind: "end" };
+}
+
+/**
+ * How many questions are still ahead, following current answers where they
+ * exist and the linear fallback where they don't.
+ *
+ * Only used for the progress bar, which is why an estimate is acceptable:
+ * with branching there is no single "total questions" to divide by, and a bar
+ * that shifts as branches resolve beats one measured against a total the
+ * respondent will never reach. Bounded by the question count, which also
+ * bounds the walk.
+ */
+export function estimateRemaining(args: {
+ flow: Flow;
+ answers: Answers;
+ fromFieldId: string;
+ visited?: readonly string[];
+}): number {
+ const { flow, answers, fromFieldId, visited = [] } = args;
+
+ const path = [...visited];
+ let cursor = fromFieldId;
+ let remaining = 0;
+
+ for (let hops = 0; hops < flow.order.length; hops++) {
+ const step = resolveNextStep({ flow, answers, fromFieldId: cursor, visited: path });
+ if (step.kind === "end") break;
+ remaining++;
+ path.push(cursor);
+ cursor = step.fieldId;
+ }
+
+ return remaining;
+}
+
+/**
+ * The answers that belong to a completed run.
+ *
+ * A respondent who answers a question, goes back, and takes a different branch
+ * leaves an answer behind for a question no longer on their path. Submitting it
+ * would record a response to a question they were never asked, quietly
+ * corrupting every per-question analytic. Only answers for questions actually
+ * visited are kept.
+ */
+export function answersOnPath(
+ answers: Answers,
+ visitedFieldIds: readonly string[],
+): Array<{ formFieldId: string; value: unknown }> {
+ const seen = new Set();
+ const result: Array<{ formFieldId: string; value: unknown }> = [];
+
+ for (const fieldId of visitedFieldIds) {
+ if (seen.has(fieldId)) continue;
+ seen.add(fieldId);
+ if (!(fieldId in answers)) continue;
+ result.push({ formFieldId: fieldId, value: answers[fieldId] });
+ }
+
+ return result;
+}
+
+/**
+ * Position of a question's segment for the "Segment 2 of 3" caption.
+ * Returns null for the implicit segment, which has no title to show.
+ */
+export function segmentProgress(
+ flow: Flow,
+ fieldId: string,
+): { segment: FlowSegment; position: number; total: number } | null {
+ const field = flow.fieldById.get(fieldId);
+ if (!field?.segmentId) return null;
+
+ const segment = flow.segmentById.get(field.segmentId);
+ if (!segment) return null;
+
+ const position = flow.segments.findIndex((s) => s.id === segment.id);
+ if (position === -1) return null;
+
+ return { segment, position: position + 1, total: flow.segments.length };
+}
+
+/* ─── Pages ───────────────────────────────────────────────────────────────
+ *
+ * Everything above answers "which question comes next". A respondent, though,
+ * sees a *page* at a time, and a page may hold one question or twenty. These
+ * turn the question-level flow into the page-level one the renderer walks,
+ * without duplicating any of the routing rules — a page's next step is still
+ * decided by the same rules, just asked once per page instead of once per
+ * question.
+ */
+
+/** What the author picked. AUTO defers to the form's shape. */
+export type QuestionLayout = "AUTO" | "ONE_PER_PAGE" | "SEGMENT_PER_PAGE" | "ALL_AT_ONCE";
+
+/** What AUTO actually became. The renderer only ever deals in these. */
+export type ResolvedLayout = "ONE_PER_PAGE" | "SEGMENT_PER_PAGE" | "ALL_AT_ONCE";
+
+export interface FlowPage {
+ /** Stable across renders: the segment id, or a synthetic id for the pages
+ * that don't correspond to one. */
+ id: string;
+ /** Present when the page is a segment, so the renderer can show its title. */
+ segment?: FlowSegment;
+ fieldIds: string[];
+}
+
+/**
+ * Can this form be shown on a single page?
+ *
+ * No, once it branches or once it has been split into segments.
+ *
+ * Branching is the hard one. A rule decides what comes *next*, which only means
+ * anything if there is a next page to decide. Put every question on one page and
+ * the routing has nowhere to apply: `buildPages` recomputes from the current
+ * answers, so questions would appear and disappear under the respondent's cursor
+ * as they filled the form in, and a branch that jumps forward would silently
+ * delete the questions it skipped from a page already on screen.
+ *
+ * Segments are the softer one, and it's about intent. Splitting a form into
+ * segments is an instruction to paginate it; flattening it back into one scroll
+ * throws away both that instruction and the segment headings, which have nowhere
+ * to render on a page that has no segment of its own.
+ */
+export function canRenderOnOnePage(segmentCount: number, ruleCount: number): boolean {
+ return ruleCount === 0 && segmentCount <= 1;
+}
+
+/**
+ * Turn the author's setting into a concrete layout.
+ *
+ * AUTO reads the form's shape rather than defaulting to a fixed mode. A form
+ * with no segments — or one — is a single run of questions and reads best one
+ * at a time. A form with several segments has already been divided into pages
+ * by its author, and showing those pages is what dividing it was for. So
+ * adding a second segment switches the form to page-per-segment on its own,
+ * which is the behaviour an author expects without having to find a setting.
+ *
+ * ALL_AT_ONCE is overridden rather than trusted, because the setting and the
+ * thing that invalidates it are edited independently: an author can choose one
+ * page for a flat form and add branching to it a week later. Enforcing the rule
+ * here means every consumer gets a layout that actually works, instead of each
+ * one having to remember to check. The stored setting is deliberately left
+ * alone, so removing the branching brings the author's choice back.
+ */
+export function resolveLayout(
+ layout: QuestionLayout | undefined,
+ segmentCount: number,
+ ruleCount = 0,
+): ResolvedLayout {
+ /** What the form's own shape asks for. Also the fallback. */
+ const paged: ResolvedLayout = segmentCount > 1 ? "SEGMENT_PER_PAGE" : "ONE_PER_PAGE";
+
+ if (!layout || layout === "AUTO") return paged;
+ if (layout === "ALL_AT_ONCE" && !canRenderOnOnePage(segmentCount, ruleCount)) return paged;
+
+ return layout;
+}
+
+/**
+ * Group a flow's questions into pages.
+ *
+ * ALL_AT_ONCE takes `answers` because with branching on, "everything" means
+ * everything the respondent's answers actually lead to — a question on a road
+ * not taken has no business being on the page. With no rules this collapses to
+ * the full list, which is the plain Google-Forms reading.
+ */
+export function buildPages(flow: Flow, layout: ResolvedLayout, answers: Answers = {}): FlowPage[] {
+ if (flow.order.length === 0) return [];
+
+ if (layout === "ONE_PER_PAGE") {
+ return flow.order.map((field) => ({ id: `q-${field.id}`, fieldIds: [field.id] }));
+ }
+
+ if (layout === "ALL_AT_ONCE") {
+ return [{ id: "all", fieldIds: reachablePath(flow, answers) }];
+ }
+
+ /* SEGMENT_PER_PAGE. Questions with no segment lead, in their own page: they
+ * only exist on forms that were never segmented, where they are the whole
+ * form, so "one page" is right for them too. */
+ const pages: FlowPage[] = [];
+
+ const unassigned = flow.order.filter((f) => !f.segmentId || !flow.segmentById.has(f.segmentId));
+ if (unassigned.length > 0) {
+ pages.push({ id: "unassigned", fieldIds: unassigned.map((f) => f.id) });
+ }
+
+ for (const segment of flow.segments) {
+ const fieldIds = flow.order.filter((f) => f.segmentId === segment.id).map((f) => f.id);
+ // Empty segments are skipped rather than shown as a blank page with a
+ // Next button, which is what an author mid-edit would otherwise publish.
+ if (fieldIds.length === 0) continue;
+ pages.push({ id: segment.id, segment, fieldIds });
+ }
+
+ return pages;
+}
+
+/**
+ * The questions the current answers actually lead through, from the start.
+ *
+ * Used by ALL_AT_ONCE, where there is no "next" to resolve but the same
+ * routing still has to decide what belongs on the page. Bounded by the
+ * question count, which also bounds the walk if rules describe a cycle.
+ */
+export function reachablePath(flow: Flow, answers: Answers): string[] {
+ const first = flow.order[0];
+ if (!first) return [];
+
+ const path: string[] = [first.id];
+ let cursor = first.id;
+
+ for (let hops = 0; hops < flow.order.length; hops++) {
+ const step = resolveNextStep({ flow, answers, fromFieldId: cursor, visited: path });
+ if (step.kind === "end") break;
+ path.push(step.fieldId);
+ cursor = step.fieldId;
+ }
+
+ return path;
+}
+
+/** The page holding a question, or -1. */
+export function pageIndexOfField(pages: readonly FlowPage[], fieldId: string): number {
+ return pages.findIndex((page) => page.fieldIds.includes(fieldId));
+}
+
+export type NextPage = { kind: "page"; pageIndex: number } | { kind: "end" };
+
+/**
+ * Where does the respondent go after finishing a page?
+ *
+ * Each question on the page is asked, in order, whether one of its rules
+ * decides — the same first-match-wins evaluation used one question at a time.
+ * The first decision that leads *off this page* wins.
+ *
+ * Decisions that land back on the same page are skipped rather than obeyed,
+ * and that's the one genuinely page-specific rule here: "jump to question 3"
+ * means nothing when question 3 is already on screen and already answerable.
+ * Obeying it would either re-show the page or, worse, skip the rest of it.
+ *
+ * No decision at all means the next page in order, which keeps an unbranched
+ * multi-page form behaving exactly as its author laid it out.
+ */
+export function resolveNextPage(args: {
+ flow: Flow;
+ pages: readonly FlowPage[];
+ answers: Answers;
+ currentPageIndex: number;
+ visitedPageIndexes?: readonly number[];
+}): NextPage {
+ const { flow, pages, answers, currentPageIndex, visitedPageIndexes = [] } = args;
+
+ const page = pages[currentPageIndex];
+ if (!page) return { kind: "end" };
+
+ const seen = new Set(visitedPageIndexes);
+
+ const guard = (pageIndex: number): NextPage => {
+ if (pageIndex < 0 || pageIndex >= pages.length) return { kind: "end" };
+ // Same cycle protection as the question-level walk: a page already visited
+ // ends the form rather than trapping the respondent in a loop.
+ if (seen.has(pageIndex)) return { kind: "end" };
+ return { kind: "page", pageIndex };
+ };
+
+ for (const fieldId of page.fieldIds) {
+ const decision = resolveRuleDecision({ flow, answers, fromFieldId: fieldId });
+ if (!decision) continue;
+
+ if (decision.kind === "end") return { kind: "end" };
+
+ const targetPage = pageIndexOfField(pages, decision.fieldId);
+ if (targetPage === -1) return { kind: "end" };
+ if (targetPage === currentPageIndex) continue;
+
+ return guard(targetPage);
+ }
+
+ return guard(currentPageIndex + 1);
+}
+
+/** Remaining pages ahead, following current answers. Progress only. */
+export function estimateRemainingPages(args: {
+ flow: Flow;
+ pages: readonly FlowPage[];
+ answers: Answers;
+ currentPageIndex: number;
+ visitedPageIndexes?: readonly number[];
+}): number {
+ const { flow, pages, answers, currentPageIndex, visitedPageIndexes = [] } = args;
+
+ const path = [...visitedPageIndexes];
+ let cursor = currentPageIndex;
+ let remaining = 0;
+
+ for (let hops = 0; hops < pages.length; hops++) {
+ const step = resolveNextPage({
+ flow,
+ pages,
+ answers,
+ currentPageIndex: cursor,
+ visitedPageIndexes: path,
+ });
+ if (step.kind === "end") break;
+ remaining++;
+ path.push(cursor);
+ cursor = step.pageIndex;
+ }
+
+ return remaining;
+}
diff --git a/apps/web/lib/form-logic.ts b/apps/web/lib/form-logic.ts
new file mode 100644
index 0000000..5d8cf57
--- /dev/null
+++ b/apps/web/lib/form-logic.ts
@@ -0,0 +1,374 @@
+/**
+ * Turning branch rules into words, and finding the ones that are broken.
+ *
+ * Both jobs belong together and belong outside the components: the sentence a
+ * rule reads as and the problems it has are properties of the rule, not of the
+ * dialog that happens to show them. Keeping them here means the inspector
+ * summary, the dialog, and any future publish-time check all say the same
+ * thing, and it can all be exercised without rendering anything.
+ */
+
+import {
+ type Flow,
+ type FlowRule,
+ type FlowCondition,
+ type LogicAction,
+ type LogicOperator,
+ MULTI_VALUE_OPERATORS,
+ VALUELESS_OPERATORS,
+} from "./form-flow";
+
+/* ─── Vocabulary ──────────────────────────────────────────────────────── */
+
+/** Reads as the middle of a sentence: "Plan **is** Pro". */
+export const OPERATOR_LABELS: Record = {
+ EQUALS: "is",
+ NOT_EQUALS: "is not",
+ CONTAINS: "contains",
+ NOT_CONTAINS: "does not contain",
+ GREATER_THAN: "is more than",
+ LESS_THAN: "is less than",
+ IS_EMPTY: "is blank",
+ IS_NOT_EMPTY: "is answered",
+ IS_ANY_OF: "is any of",
+ IS_NONE_OF: "is none of",
+ STARTS_WITH: "starts with",
+ ENDS_WITH: "ends with",
+};
+
+export const ACTION_LABELS: Record = {
+ JUMP_TO_FIELD: "Go to question",
+ JUMP_TO_SEGMENT: "Go to segment",
+ SUBMIT: "Finish the form",
+ CONTINUE: "Continue in order",
+};
+
+/** Operators worth offering for a given question type. Showing "is more than"
+ * on a checkbox question invites a rule that can never match, so the list is
+ * narrowed rather than explained. */
+export function operatorsForFieldType(type: string | undefined): LogicOperator[] {
+ const presence: LogicOperator[] = ["IS_EMPTY", "IS_NOT_EMPTY"];
+
+ switch (type) {
+ case "SELECT":
+ case "RADIO":
+ return ["EQUALS", "NOT_EQUALS", "IS_ANY_OF", "IS_NONE_OF", ...presence];
+ case "CHECKBOX":
+ return ["CONTAINS", "NOT_CONTAINS", "IS_ANY_OF", "IS_NONE_OF", "EQUALS", ...presence];
+ case "NUMBER":
+ case "RATING":
+ case "SLIDER":
+ return ["EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", ...presence];
+ case "DATE":
+ case "TIME":
+ case "DATETIME":
+ return ["EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", ...presence];
+ case "TOGGLE":
+ return ["EQUALS", ...presence];
+ default:
+ // Free text and everything unrecognised.
+ return [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "STARTS_WITH",
+ "ENDS_WITH",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ ...presence,
+ ];
+ }
+}
+
+/** The fixed choices a question offers, if it has any. Mirrors how the builder
+ * and renderer both read `options`. */
+export function choicesForField(field: { options?: unknown } | undefined): string[] {
+ if (!field) return [];
+ const options = field.options;
+ if (Array.isArray(options)) return options.map(String);
+ if (options && typeof options === "object") {
+ const choices = (options as { choices?: unknown }).choices;
+ if (Array.isArray(choices)) return choices.map(String);
+ }
+ return [];
+}
+
+/* ─── Describing ──────────────────────────────────────────────────────── */
+
+export interface FlowLabels {
+ /** Display name for a question, already numbered by the caller. */
+ fieldLabel: (fieldId: string) => string;
+ segmentLabel: (segmentId: string) => string;
+}
+
+function formatValue(condition: FlowCondition): string {
+ if (VALUELESS_OPERATORS.includes(condition.operator)) return "";
+ const { value } = condition;
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) return "…";
+ if (value.length === 1) return `“${String(value[0])}”`;
+ return value.map((v) => `“${String(v)}”`).join(" or ");
+ }
+ if (value === undefined || value === null || value === "") return "…";
+ return `“${String(value)}”`;
+}
+
+export function describeCondition(condition: FlowCondition, labels: FlowLabels): string {
+ const operator = OPERATOR_LABELS[condition.operator] ?? condition.operator;
+ const value = formatValue(condition);
+ return [labels.fieldLabel(condition.fieldId), operator, value].filter(Boolean).join(" ");
+}
+
+function describeSide(
+ action: LogicAction | null | undefined,
+ targetFieldId: string | null | undefined,
+ targetSegmentId: string | null | undefined,
+ labels: FlowLabels,
+): string {
+ switch (action) {
+ case "SUBMIT":
+ return "finish the form";
+ case "CONTINUE":
+ return "continue in order";
+ case "JUMP_TO_FIELD":
+ return targetFieldId ? `go to ${labels.fieldLabel(targetFieldId)}` : "go to …";
+ case "JUMP_TO_SEGMENT":
+ return targetSegmentId ? `go to ${labels.segmentLabel(targetSegmentId)}` : "go to …";
+ default:
+ return "";
+ }
+}
+
+/**
+ * A rule as one sentence, e.g.
+ * "If all of: Plan is “Pro”, Team size is more than “50” → go to Q7,
+ * otherwise finish the form".
+ *
+ * The summary exists so the inspector can show what a branch does without
+ * enough room to show its controls, and so the dialog can label a collapsed
+ * rule. It's the same text in both places on purpose.
+ */
+export function describeRule(rule: FlowRule, labels: FlowLabels): string {
+ const joiner = rule.match === "ANY" ? "any of" : "all of";
+
+ const conditions =
+ rule.conditions.length === 0
+ ? "no conditions yet"
+ : rule.conditions.map((c) => describeCondition(c, labels)).join(", ");
+
+ const then = describeSide(rule.action, rule.targetFieldId, rule.targetSegmentId, labels);
+ const otherwise = rule.elseAction
+ ? describeSide(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId, labels)
+ : null;
+
+ const head = rule.conditions.length === 1 ? `If ${conditions}` : `If ${joiner}: ${conditions}`;
+
+ return otherwise ? `${head} → ${then}, otherwise ${otherwise}` : `${head} → ${then}`;
+}
+
+/* ─── Linting ─────────────────────────────────────────────────────────── */
+
+export type FlowIssueLevel = "error" | "warning";
+
+export interface FlowIssue {
+ level: FlowIssueLevel;
+ message: string;
+ /** The rule this is about, when it is about one. */
+ ruleId?: string;
+ /** The question this is about, for issues that aren't rule-specific. */
+ fieldId?: string;
+}
+
+function isConditionComplete(condition: FlowCondition): boolean {
+ if (VALUELESS_OPERATORS.includes(condition.operator)) return true;
+ if (MULTI_VALUE_OPERATORS.includes(condition.operator)) {
+ return Array.isArray(condition.value) && condition.value.length > 0;
+ }
+ return condition.value !== undefined && condition.value !== null && condition.value !== "";
+}
+
+function sideIsComplete(
+ action: LogicAction | null | undefined,
+ targetFieldId: string | null | undefined,
+ targetSegmentId: string | null | undefined,
+): boolean {
+ if (action === "JUMP_TO_FIELD") return !!targetFieldId;
+ if (action === "JUMP_TO_SEGMENT") return !!targetSegmentId;
+ return true;
+}
+
+/** Whether a rule is finished enough to be saved and to have any effect. */
+export function isRuleComplete(rule: FlowRule): boolean {
+ if (rule.conditions.length === 0) return false;
+ if (!rule.conditions.every(isConditionComplete)) return false;
+ if (!sideIsComplete(rule.action, rule.targetFieldId, rule.targetSegmentId)) return false;
+ if (
+ rule.elseAction &&
+ !sideIsComplete(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId)
+ )
+ return false;
+ return true;
+}
+
+function firstFieldOfSegmentForLint(flow: Flow, segmentId: string): string | null {
+ const start = flow.segments.findIndex((s) => s.id === segmentId);
+ if (start === -1) return null;
+ for (let i = start; i < flow.segments.length; i++) {
+ const segment = flow.segments[i];
+ if (!segment) continue;
+ const field = flow.order.find((f) => f.segmentId === segment.id);
+ if (field) return field.id;
+ }
+ return null;
+}
+
+/**
+ * Problems worth showing the author before they publish.
+ *
+ * Reachability is computed over the union of every edge any answer could
+ * follow, which deliberately over-approximates: a question this says is
+ * reachable might still be hard to reach, but a question it calls unreachable
+ * genuinely cannot be shown to anyone. Erring that way matters because the
+ * alternative — enumerating answer combinations — is exponential, and a lint
+ * that cries wolf gets ignored.
+ */
+export function lintFlow(flow: Flow, labels: FlowLabels): FlowIssue[] {
+ const issues: FlowIssue[] = [];
+ const allRules = [...flow.rulesByFieldId.values()].flat();
+
+ for (const rule of allRules) {
+ const where = labels.fieldLabel(rule.fieldId);
+
+ if (rule.conditions.length === 0) {
+ issues.push({
+ level: "warning",
+ ruleId: rule.id,
+ message: `A branch on ${where} has no conditions, so it never runs.`,
+ });
+ }
+
+ for (const condition of rule.conditions) {
+ if (!isConditionComplete(condition)) {
+ issues.push({
+ level: "error",
+ ruleId: rule.id,
+ message: `A condition on ${where} is missing the value to compare against.`,
+ });
+ }
+ if (!flow.fieldById.has(condition.fieldId)) {
+ issues.push({
+ level: "error",
+ ruleId: rule.id,
+ message: `A branch on ${where} reads a question that no longer exists.`,
+ });
+ }
+ }
+
+ if (!sideIsComplete(rule.action, rule.targetFieldId, rule.targetSegmentId)) {
+ issues.push({
+ level: "error",
+ ruleId: rule.id,
+ message: `A branch on ${where} does not say where a match should go.`,
+ });
+ }
+ if (
+ rule.elseAction &&
+ !sideIsComplete(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId)
+ ) {
+ issues.push({
+ level: "error",
+ ruleId: rule.id,
+ message: `The otherwise branch on ${where} does not say where to go.`,
+ });
+ }
+
+ // A jump backwards is legal but almost always a mistake: the respondent
+ // has already answered that question, and the visited-set guard will end
+ // the form rather than ask it again.
+ const from = flow.positionById.get(rule.fieldId);
+ for (const target of [rule.targetFieldId, rule.elseTargetFieldId]) {
+ if (!target) continue;
+ const to = flow.positionById.get(target);
+ if (from !== undefined && to !== undefined && to <= from) {
+ issues.push({
+ level: "warning",
+ ruleId: rule.id,
+ message: `A branch on ${where} jumps backwards to ${labels.fieldLabel(target)}. Respondents who reach it a second time will finish the form instead.`,
+ });
+ }
+ }
+ }
+
+ // Any rule after one that always decides can never be reached, because the
+ // earlier rule returns on both its branches.
+ for (const [fieldId, rules] of flow.rulesByFieldId) {
+ const decisive = rules.findIndex((r) => !!r.elseAction && isRuleComplete(r));
+ if (decisive === -1) continue;
+ for (const shadowed of rules.slice(decisive + 1)) {
+ issues.push({
+ level: "warning",
+ ruleId: shadowed.id,
+ message: `This branch never runs: an earlier branch on ${labels.fieldLabel(fieldId)} already handles both outcomes.`,
+ });
+ }
+ }
+
+ /* Reachability over the union of possible edges. */
+ const edges = new Map>();
+ const addEdge = (from: string, to: string | null | undefined) => {
+ if (!to) return;
+ const set = edges.get(from) ?? new Set();
+ set.add(to);
+ edges.set(from, set);
+ };
+
+ flow.order.forEach((field, i) => {
+ const rules = flow.rulesByFieldId.get(field.id) ?? [];
+ const next = flow.order[i + 1]?.id;
+
+ // The linear edge survives unless every rule here decides both ways —
+ // in which case nothing ever simply falls through.
+ const alwaysDecided =
+ rules.length > 0 && rules.some((r) => !!r.elseAction && isRuleComplete(r));
+ const anyContinue = rules.some((r) => r.action === "CONTINUE" || r.elseAction === "CONTINUE");
+ if (!alwaysDecided || anyContinue) addEdge(field.id, next);
+
+ for (const rule of rules) {
+ addEdge(field.id, rule.targetFieldId);
+ addEdge(field.id, rule.elseTargetFieldId);
+ if (rule.targetSegmentId) {
+ addEdge(field.id, firstFieldOfSegmentForLint(flow, rule.targetSegmentId));
+ }
+ if (rule.elseTargetSegmentId) {
+ addEdge(field.id, firstFieldOfSegmentForLint(flow, rule.elseTargetSegmentId));
+ }
+ }
+ });
+
+ const start = flow.order[0]?.id;
+ if (start) {
+ const reached = new Set([start]);
+ const queue = [start];
+ while (queue.length > 0) {
+ const current = queue.shift() as string;
+ for (const next of edges.get(current) ?? []) {
+ if (reached.has(next)) continue;
+ reached.add(next);
+ queue.push(next);
+ }
+ }
+
+ for (const field of flow.order) {
+ if (reached.has(field.id)) continue;
+ issues.push({
+ level: "warning",
+ fieldId: field.id,
+ message: `${labels.fieldLabel(field.id)} can't be reached by any answer.`,
+ });
+ }
+ }
+
+ return issues;
+}
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
index a5ef193..3256b0c 100644
--- a/apps/web/lib/utils.ts
+++ b/apps/web/lib/utils.ts
@@ -4,3 +4,32 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+/**
+ * Where to go after signing in, taken from a `?redirect=` parameter.
+ *
+ * Exists because a respondent sent to sign-in from a gated form has to come
+ * back to *that form*, not to the dashboard. Without it the sign-in wall is a
+ * dead end: they authenticate and land somewhere unrelated, with no obvious way
+ * back to the thing they were asked to fill in.
+ *
+ * Only same-origin paths are honoured, and the check is a whitelist rather than
+ * a blacklist. An unvalidated `redirect` is an open redirect — an attacker links
+ * to our sign-in page with `?redirect=https://look-alike.example`, the victim
+ * signs in for real, and we hand them to the impostor with our domain in the
+ * referrer chain. So:
+ *
+ * - must begin with a single `/`, which rules out absolute URLs entirely
+ * - `//host` and `/\host` are rejected: both are protocol-relative and would
+ * leave the origin despite starting with a slash
+ * - anything else falls back to `fallback`, never to the raw input
+ */
+export function safeRedirect(target: string | null | undefined, fallback = "/dashboard"): string {
+ if (!target) return fallback;
+
+ const path = target.trim();
+ if (!path.startsWith("/")) return fallback;
+ if (path.startsWith("//") || path.startsWith("/\\")) return fallback;
+
+ return path;
+}
diff --git a/apps/web/providers/global.tsx b/apps/web/providers/global.tsx
index 84b9604..4678ae7 100644
--- a/apps/web/providers/global.tsx
+++ b/apps/web/providers/global.tsx
@@ -10,7 +10,7 @@ import { createTRPCHttpBatchClientClient } from "~/trpc/create-client";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
- // 5 min stale time — form lists, dashboard stats, user plan etc. only
+ // 5 min stale time — form lists, dashboard stats, the current user etc. only
// change on explicit user action, not passively. Avoids background
// refetch storms when navigating between pages sharing queries.
staleTime: 5 * 60 * 1000,
diff --git a/docker-compose.yml b/docker-compose.yml
index 049e935..1b12b5a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,19 +1,41 @@
services:
- postgresdb:
+ postgres:
image: postgres:15
+ container_name: canvasflow-postgres
restart: unless-stopped
- container_name: postgresdb
environment:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: dev
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
+ POSTGRES_DB: ${POSTGRES_DB:-dev}
+ # Host port 5434, not the usual 5432. Other Postgres containers on this
+ # machine already claim 5432 (chatapp) and 5433 (jobcrawler), and a
+ # collision there fails the container at start with "address already in
+ # use". Override with POSTGRES_PORT in .env if 5434 is taken too — just
+ # remember to update DATABASE_URL to match.
ports:
- - "5432:5432"
+ - "${POSTGRES_PORT:-5434}:5432"
volumes:
- pg_data:/var/lib/postgresql/data
+ # `docker compose up -d` returns as soon as the container is *running*,
+ # but Postgres needs a few more seconds before it accepts connections.
+ # Without this, `pnpm db:migrate` immediately after `up` loses the race
+ # with ECONNREFUSED. `--host` + `-p` force pg_isready over TCP (matching
+ # how the app connects) rather than the local unix socket, which comes
+ # up earlier and would report ready too soon.
+ 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
+
volumes:
pg_data:
diff --git a/package.json b/package.json
index 570f58a..09f69a1 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,13 @@
"private": true,
"scripts": {
"build": "dotenv -- turbo build",
- "dev": "dotenv -- turbo dev",
+ "dev": "pnpm db:up && dotenv -- turbo dev",
+ "dev:no-db": "dotenv -- turbo dev",
+ "db:up": "docker compose up -d --wait",
+ "db:down": "docker compose down",
+ "db:logs": "docker compose logs -f postgres",
+ "db:psql": "docker compose exec postgres psql -U postgres -d dev",
+ "db:reset": "docker compose down -v && pnpm db:up && pnpm db:migrate",
"db:generate": "dotenv -- turbo db:generate",
"db:migrate": "dotenv -- turbo db:migrate",
"lint": "turbo run lint",
diff --git a/packages/database/drizzle/0013_big_bruce_banner.sql b/packages/database/drizzle/0013_big_bruce_banner.sql
new file mode 100644
index 0000000..6c9b625
--- /dev/null
+++ b/packages/database/drizzle/0013_big_bruce_banner.sql
@@ -0,0 +1,50 @@
+CREATE TYPE "public"."logic_action" AS ENUM('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT', 'SUBMIT');--> statement-breakpoint
+CREATE TYPE "public"."logic_operator" AS ENUM('EQUALS', 'NOT_EQUALS', 'CONTAINS', 'NOT_CONTAINS', 'GREATER_THAN', 'LESS_THAN', 'IS_EMPTY', 'IS_NOT_EMPTY');--> statement-breakpoint
+CREATE TABLE "form_segments" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "form_id" uuid NOT NULL,
+ "title" varchar(255) NOT NULL,
+ "description" text,
+ "index" numeric NOT NULL,
+ "version" integer DEFAULT 0 NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "form_segments_form_id_index_unique" UNIQUE("form_id","index")
+);
+--> statement-breakpoint
+CREATE TABLE "form_logic_rules" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "form_id" uuid NOT NULL,
+ "field_id" uuid NOT NULL,
+ "operator" "logic_operator" NOT NULL,
+ "value" jsonb,
+ "action" "logic_action" NOT NULL,
+ "target_field_id" uuid,
+ "target_segment_id" uuid,
+ "index" numeric NOT NULL,
+ "version" integer DEFAULT 0 NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "form_logic_rules_target_matches_action" CHECK ((
+ ("form_logic_rules"."action" = 'JUMP_TO_FIELD' AND "form_logic_rules"."target_field_id" IS NOT NULL AND "form_logic_rules"."target_segment_id" IS NULL)
+ OR ("form_logic_rules"."action" = 'JUMP_TO_SEGMENT' AND "form_logic_rules"."target_segment_id" IS NOT NULL AND "form_logic_rules"."target_field_id" IS NULL)
+ OR ("form_logic_rules"."action" = 'SUBMIT' AND "form_logic_rules"."target_field_id" IS NULL AND "form_logic_rules"."target_segment_id" IS NULL)
+ )),
+ CONSTRAINT "form_logic_rules_value_matches_operator" CHECK ((
+ ("form_logic_rules"."operator" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND "form_logic_rules"."value" IS NULL)
+ OR ("form_logic_rules"."operator" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND "form_logic_rules"."value" IS NOT NULL)
+ )),
+ CONSTRAINT "form_logic_rules_no_self_jump" CHECK ("form_logic_rules"."target_field_id" IS NULL OR "form_logic_rules"."target_field_id" <> "form_logic_rules"."field_id")
+);
+--> statement-breakpoint
+ALTER TABLE "form_fields" ADD COLUMN "segment_id" uuid;--> statement-breakpoint
+ALTER TABLE "form_segments" ADD CONSTRAINT "form_segments_form_id_forms_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_form_id_forms_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_field_id_form_fields_id_fk" FOREIGN KEY ("field_id") REFERENCES "public"."form_fields"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_target_field_id_form_fields_id_fk" FOREIGN KEY ("target_field_id") REFERENCES "public"."form_fields"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_target_segment_id_form_segments_id_fk" FOREIGN KEY ("target_segment_id") REFERENCES "public"."form_segments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "form_segments_form_idx" ON "form_segments" USING btree ("form_id");--> statement-breakpoint
+CREATE INDEX "form_logic_rules_form_idx" ON "form_logic_rules" USING btree ("form_id");--> statement-breakpoint
+CREATE INDEX "form_logic_rules_field_idx" ON "form_logic_rules" USING btree ("field_id","index");--> statement-breakpoint
+ALTER TABLE "form_fields" ADD CONSTRAINT "form_fields_segment_id_form_segments_id_fk" FOREIGN KEY ("segment_id") REFERENCES "public"."form_segments"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "form_fields_segment_idx" ON "form_fields" USING btree ("segment_id","index");
\ No newline at end of file
diff --git a/packages/database/drizzle/0014_plain_norman_osborn.sql b/packages/database/drizzle/0014_plain_norman_osborn.sql
new file mode 100644
index 0000000..d273396
--- /dev/null
+++ b/packages/database/drizzle/0014_plain_norman_osborn.sql
@@ -0,0 +1,62 @@
+CREATE TYPE "public"."logic_match" AS ENUM('ALL', 'ANY');--> statement-breakpoint
+ALTER TYPE "public"."logic_action" ADD VALUE 'CONTINUE';--> statement-breakpoint
+ALTER TYPE "public"."logic_operator" ADD VALUE 'IS_ANY_OF';--> statement-breakpoint
+ALTER TYPE "public"."logic_operator" ADD VALUE 'IS_NONE_OF';--> statement-breakpoint
+ALTER TYPE "public"."logic_operator" ADD VALUE 'STARTS_WITH';--> statement-breakpoint
+ALTER TYPE "public"."logic_operator" ADD VALUE 'ENDS_WITH';--> statement-breakpoint
+CREATE TABLE "form_logic_conditions" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "rule_id" uuid NOT NULL,
+ "field_id" uuid NOT NULL,
+ "operator" "logic_operator" NOT NULL,
+ "value" jsonb,
+ "index" numeric NOT NULL,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "form_logic_conditions_value_matches_operator" CHECK ((
+ ("form_logic_conditions"."operator" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND "form_logic_conditions"."value" IS NULL)
+ OR ("form_logic_conditions"."operator" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND "form_logic_conditions"."value" IS NOT NULL)
+ ))
+);
+--> statement-breakpoint
+ALTER TABLE "form_logic_rules" DROP CONSTRAINT "form_logic_rules_value_matches_operator";--> statement-breakpoint
+ALTER TABLE "form_logic_rules" DROP CONSTRAINT "form_logic_rules_target_matches_action";--> statement-breakpoint
+ALTER TABLE "form_logic_rules" DROP CONSTRAINT "form_logic_rules_no_self_jump";--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ALTER COLUMN "operator" DROP NOT NULL;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD COLUMN "match" "logic_match" DEFAULT 'ALL' NOT NULL;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD COLUMN "else_action" "logic_action";--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD COLUMN "else_target_field_id" uuid;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD COLUMN "else_target_segment_id" uuid;--> statement-breakpoint
+ALTER TABLE "form_logic_conditions" ADD CONSTRAINT "form_logic_conditions_rule_id_form_logic_rules_id_fk" FOREIGN KEY ("rule_id") REFERENCES "public"."form_logic_rules"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_conditions" ADD CONSTRAINT "form_logic_conditions_field_id_form_fields_id_fk" FOREIGN KEY ("field_id") REFERENCES "public"."form_fields"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "form_logic_conditions_rule_idx" ON "form_logic_conditions" USING btree ("rule_id","index");--> statement-breakpoint
+CREATE INDEX "form_logic_conditions_field_idx" ON "form_logic_conditions" USING btree ("field_id");--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_else_target_field_id_form_fields_id_fk" FOREIGN KEY ("else_target_field_id") REFERENCES "public"."form_fields"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_else_target_segment_id_form_segments_id_fk" FOREIGN KEY ("else_target_segment_id") REFERENCES "public"."form_segments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_else_target_matches_action" CHECK ((
+ ("form_logic_rules"."else_action" = 'JUMP_TO_FIELD' AND "form_logic_rules"."else_target_field_id" IS NOT NULL AND "form_logic_rules"."else_target_segment_id" IS NULL)
+ OR ("form_logic_rules"."else_action" = 'JUMP_TO_SEGMENT' AND "form_logic_rules"."else_target_segment_id" IS NOT NULL AND "form_logic_rules"."else_target_field_id" IS NULL)
+ OR (
+ ("form_logic_rules"."else_action" IS NULL OR "form_logic_rules"."else_action" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))
+ AND "form_logic_rules"."else_target_field_id" IS NULL
+ AND "form_logic_rules"."else_target_segment_id" IS NULL
+ )
+ ));--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_target_matches_action" CHECK ((
+ ("form_logic_rules"."action" = 'JUMP_TO_FIELD' AND "form_logic_rules"."target_field_id" IS NOT NULL AND "form_logic_rules"."target_segment_id" IS NULL)
+ OR ("form_logic_rules"."action" = 'JUMP_TO_SEGMENT' AND "form_logic_rules"."target_segment_id" IS NOT NULL AND "form_logic_rules"."target_field_id" IS NULL)
+ OR ("form_logic_rules"."action" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND "form_logic_rules"."target_field_id" IS NULL AND "form_logic_rules"."target_segment_id" IS NULL)
+ ));--> statement-breakpoint
+ALTER TABLE "form_logic_rules" ADD CONSTRAINT "form_logic_rules_no_self_jump" CHECK (("form_logic_rules"."target_field_id" IS NULL OR "form_logic_rules"."target_field_id" <> "form_logic_rules"."field_id")
+ AND ("form_logic_rules"."else_target_field_id" IS NULL OR "form_logic_rules"."else_target_field_id" <> "form_logic_rules"."field_id"));
+--> statement-breakpoint
+-- Back-fill, hand-written: every rule created under the single-condition
+-- schema becomes a one-condition rule. The rule's own operator/value WERE the
+-- condition, and it read the trigger question's answer, so all three move
+-- across verbatim and existing branching keeps behaving identically.
+--
+-- Must run before migration 0015 drops the source columns.
+INSERT INTO "form_logic_conditions" ("rule_id", "field_id", "operator", "value", "index")
+SELECT "id", "field_id", "operator", "value", 1
+FROM "form_logic_rules"
+WHERE "operator" IS NOT NULL;
diff --git a/packages/database/drizzle/0015_good_captain_marvel.sql b/packages/database/drizzle/0015_good_captain_marvel.sql
new file mode 100644
index 0000000..e6eed0b
--- /dev/null
+++ b/packages/database/drizzle/0015_good_captain_marvel.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "form_logic_rules" DROP COLUMN "operator";--> statement-breakpoint
+ALTER TABLE "form_logic_rules" DROP COLUMN "value";
\ No newline at end of file
diff --git a/packages/database/drizzle/0016_spooky_the_fallen.sql b/packages/database/drizzle/0016_spooky_the_fallen.sql
new file mode 100644
index 0000000..a932baf
--- /dev/null
+++ b/packages/database/drizzle/0016_spooky_the_fallen.sql
@@ -0,0 +1,2 @@
+CREATE TYPE "public"."question_layout" AS ENUM('AUTO', 'ONE_PER_PAGE', 'SEGMENT_PER_PAGE', 'ALL_AT_ONCE');--> statement-breakpoint
+ALTER TABLE "forms" ADD COLUMN "question_layout" "question_layout" DEFAULT 'AUTO' NOT NULL;
\ No newline at end of file
diff --git a/packages/database/drizzle/0017_nebulous_hellcat.sql b/packages/database/drizzle/0017_nebulous_hellcat.sql
new file mode 100644
index 0000000..6ebb75c
--- /dev/null
+++ b/packages/database/drizzle/0017_nebulous_hellcat.sql
@@ -0,0 +1,3 @@
+ALTER TABLE "users" DROP COLUMN "plan";--> statement-breakpoint
+ALTER TABLE "forms" DROP COLUMN "max_submissions";--> statement-breakpoint
+DROP TYPE "public"."subscription_type";
\ No newline at end of file
diff --git a/packages/database/drizzle/0018_perfect_molten_man.sql b/packages/database/drizzle/0018_perfect_molten_man.sql
new file mode 100644
index 0000000..8f3ab5a
--- /dev/null
+++ b/packages/database/drizzle/0018_perfect_molten_man.sql
@@ -0,0 +1,14 @@
+CREATE TABLE "form_drafts" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "form_id" uuid NOT NULL,
+ "user_id" text NOT NULL,
+ "values" jsonb NOT NULL,
+ "page_path" jsonb,
+ "created_at" timestamp DEFAULT now() NOT NULL,
+ "updated_at" timestamp DEFAULT now() NOT NULL,
+ CONSTRAINT "form_drafts_form_id_user_id_unique" UNIQUE("form_id","user_id")
+);
+--> statement-breakpoint
+ALTER TABLE "form_drafts" ADD CONSTRAINT "form_drafts_form_id_forms_id_fk" FOREIGN KEY ("form_id") REFERENCES "public"."forms"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "form_drafts" ADD CONSTRAINT "form_drafts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "form_drafts_user_form_idx" ON "form_drafts" USING btree ("user_id","form_id");
\ No newline at end of file
diff --git a/packages/database/drizzle/0019_dashing_veda.sql b/packages/database/drizzle/0019_dashing_veda.sql
new file mode 100644
index 0000000..f24fc8f
--- /dev/null
+++ b/packages/database/drizzle/0019_dashing_veda.sql
@@ -0,0 +1,10 @@
+DROP INDEX "form_submissions_form_visitor_idx";--> statement-breakpoint
+ALTER TABLE "forms" ADD COLUMN "require_sign_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "forms" ADD COLUMN "collect_respondent_email" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "forms" ADD COLUMN "one_response_per_respondent" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "forms" ADD COLUMN "allowed_email_domains" jsonb;--> statement-breakpoint
+ALTER TABLE "forms" ADD COLUMN "thank_you_message" text;--> statement-breakpoint
+ALTER TABLE "form_submissions" ADD COLUMN "respondent_user_id" text;--> statement-breakpoint
+ALTER TABLE "form_submissions" ADD COLUMN "respondent_email" varchar(255);--> statement-breakpoint
+ALTER TABLE "form_submissions" ADD CONSTRAINT "form_submissions_respondent_user_id_users_id_fk" FOREIGN KEY ("respondent_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "form_submissions_form_respondent_idx" ON "form_submissions" USING btree ("form_id","respondent_user_id");
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0013_snapshot.json b/packages/database/drizzle/meta/0013_snapshot.json
new file mode 100644
index 0000000..df71f6d
--- /dev/null
+++ b/packages/database/drizzle/meta/0013_snapshot.json
@@ -0,0 +1,1702 @@
+{
+ "id": "381af1cd-d18f-4fee-95ee-c2a9a12bc156",
+ "prevId": "d74549cd-8d8f-4a25-b1d4-8941699f4db3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "subscription_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Free'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_submissions": {
+ "name": "max_submissions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'SUBMIT' AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_value_matches_operator": {
+ "name": "form_logic_rules_value_matches_operator",
+ "value": "(\n (\"form_logic_rules\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_rules\".\"value\" IS NULL)\n OR (\"form_logic_rules\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_rules\".\"value\" IS NOT NULL)\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_visitor_idx": {
+ "name": "form_submissions_form_visitor_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visitor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"visitor_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.subscription_type": {
+ "name": "subscription_type",
+ "schema": "public",
+ "values": [
+ "Free",
+ "Pro",
+ "Pro+",
+ "Business"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0014_snapshot.json b/packages/database/drizzle/meta/0014_snapshot.json
new file mode 100644
index 0000000..21963b9
--- /dev/null
+++ b/packages/database/drizzle/meta/0014_snapshot.json
@@ -0,0 +1,1902 @@
+{
+ "id": "4aad88a8-818e-4812-9af0-af4325a7b555",
+ "prevId": "381af1cd-d18f-4fee-95ee-c2a9a12bc156",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "subscription_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Free'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_submissions": {
+ "name": "max_submissions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_conditions": {
+ "name": "form_logic_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_conditions_rule_idx": {
+ "name": "form_logic_conditions_rule_idx",
+ "columns": [
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_conditions_field_idx": {
+ "name": "form_logic_conditions_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_conditions_rule_id_form_logic_rules_id_fk": {
+ "name": "form_logic_conditions_rule_id_form_logic_rules_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_logic_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_conditions_field_id_form_fields_id_fk": {
+ "name": "form_logic_conditions_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_conditions_value_matches_operator": {
+ "name": "form_logic_conditions_value_matches_operator",
+ "value": "(\n (\"form_logic_conditions\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NULL)\n OR (\"form_logic_conditions\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match": {
+ "name": "match",
+ "type": "logic_match",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ALL'"
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_action": {
+ "name": "else_action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_field_id": {
+ "name": "else_target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_segment_id": {
+ "name": "else_target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_else_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "else_target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_else_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "else_target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_else_target_matches_action": {
+ "name": "form_logic_rules_else_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"else_target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"else_target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_field_id\" IS NULL)\n OR (\n (\"form_logic_rules\".\"else_action\" IS NULL OR \"form_logic_rules\".\"else_action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))\n AND \"form_logic_rules\".\"else_target_field_id\" IS NULL\n AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL\n )\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "(\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\")\n AND (\"form_logic_rules\".\"else_target_field_id\" IS NULL OR \"form_logic_rules\".\"else_target_field_id\" <> \"form_logic_rules\".\"field_id\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_visitor_idx": {
+ "name": "form_submissions_form_visitor_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visitor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"visitor_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.subscription_type": {
+ "name": "subscription_type",
+ "schema": "public",
+ "values": [
+ "Free",
+ "Pro",
+ "Pro+",
+ "Business"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE"
+ ]
+ },
+ "public.logic_match": {
+ "name": "logic_match",
+ "schema": "public",
+ "values": [
+ "ALL",
+ "ANY"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0015_snapshot.json b/packages/database/drizzle/meta/0015_snapshot.json
new file mode 100644
index 0000000..74a5c7e
--- /dev/null
+++ b/packages/database/drizzle/meta/0015_snapshot.json
@@ -0,0 +1,1889 @@
+{
+ "id": "e22ea1c4-a146-41a7-a392-345683ea06bb",
+ "prevId": "4aad88a8-818e-4812-9af0-af4325a7b555",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "subscription_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Free'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_submissions": {
+ "name": "max_submissions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_conditions": {
+ "name": "form_logic_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_conditions_rule_idx": {
+ "name": "form_logic_conditions_rule_idx",
+ "columns": [
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_conditions_field_idx": {
+ "name": "form_logic_conditions_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_conditions_rule_id_form_logic_rules_id_fk": {
+ "name": "form_logic_conditions_rule_id_form_logic_rules_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_logic_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_conditions_field_id_form_fields_id_fk": {
+ "name": "form_logic_conditions_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_conditions_value_matches_operator": {
+ "name": "form_logic_conditions_value_matches_operator",
+ "value": "(\n (\"form_logic_conditions\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NULL)\n OR (\"form_logic_conditions\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match": {
+ "name": "match",
+ "type": "logic_match",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ALL'"
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_action": {
+ "name": "else_action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_field_id": {
+ "name": "else_target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_segment_id": {
+ "name": "else_target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_else_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "else_target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_else_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "else_target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_else_target_matches_action": {
+ "name": "form_logic_rules_else_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"else_target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"else_target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_field_id\" IS NULL)\n OR (\n (\"form_logic_rules\".\"else_action\" IS NULL OR \"form_logic_rules\".\"else_action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))\n AND \"form_logic_rules\".\"else_target_field_id\" IS NULL\n AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL\n )\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "(\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\")\n AND (\"form_logic_rules\".\"else_target_field_id\" IS NULL OR \"form_logic_rules\".\"else_target_field_id\" <> \"form_logic_rules\".\"field_id\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_visitor_idx": {
+ "name": "form_submissions_form_visitor_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visitor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"visitor_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.subscription_type": {
+ "name": "subscription_type",
+ "schema": "public",
+ "values": [
+ "Free",
+ "Pro",
+ "Pro+",
+ "Business"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE"
+ ]
+ },
+ "public.logic_match": {
+ "name": "logic_match",
+ "schema": "public",
+ "values": [
+ "ALL",
+ "ANY"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0016_snapshot.json b/packages/database/drizzle/meta/0016_snapshot.json
new file mode 100644
index 0000000..e507a88
--- /dev/null
+++ b/packages/database/drizzle/meta/0016_snapshot.json
@@ -0,0 +1,1907 @@
+{
+ "id": "9d96b179-748d-4417-b071-1cc351642104",
+ "prevId": "e22ea1c4-a146-41a7-a392-345683ea06bb",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "subscription_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Free'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_submissions": {
+ "name": "max_submissions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "question_layout": {
+ "name": "question_layout",
+ "type": "question_layout",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'AUTO'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_conditions": {
+ "name": "form_logic_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_conditions_rule_idx": {
+ "name": "form_logic_conditions_rule_idx",
+ "columns": [
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_conditions_field_idx": {
+ "name": "form_logic_conditions_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_conditions_rule_id_form_logic_rules_id_fk": {
+ "name": "form_logic_conditions_rule_id_form_logic_rules_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_logic_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_conditions_field_id_form_fields_id_fk": {
+ "name": "form_logic_conditions_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_conditions_value_matches_operator": {
+ "name": "form_logic_conditions_value_matches_operator",
+ "value": "(\n (\"form_logic_conditions\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NULL)\n OR (\"form_logic_conditions\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match": {
+ "name": "match",
+ "type": "logic_match",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ALL'"
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_action": {
+ "name": "else_action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_field_id": {
+ "name": "else_target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_segment_id": {
+ "name": "else_target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_else_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "else_target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_else_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "else_target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_else_target_matches_action": {
+ "name": "form_logic_rules_else_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"else_target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"else_target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_field_id\" IS NULL)\n OR (\n (\"form_logic_rules\".\"else_action\" IS NULL OR \"form_logic_rules\".\"else_action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))\n AND \"form_logic_rules\".\"else_target_field_id\" IS NULL\n AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL\n )\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "(\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\")\n AND (\"form_logic_rules\".\"else_target_field_id\" IS NULL OR \"form_logic_rules\".\"else_target_field_id\" <> \"form_logic_rules\".\"field_id\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_visitor_idx": {
+ "name": "form_submissions_form_visitor_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visitor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"visitor_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.subscription_type": {
+ "name": "subscription_type",
+ "schema": "public",
+ "values": [
+ "Free",
+ "Pro",
+ "Pro+",
+ "Business"
+ ]
+ },
+ "public.question_layout": {
+ "name": "question_layout",
+ "schema": "public",
+ "values": [
+ "AUTO",
+ "ONE_PER_PAGE",
+ "SEGMENT_PER_PAGE",
+ "ALL_AT_ONCE"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE"
+ ]
+ },
+ "public.logic_match": {
+ "name": "logic_match",
+ "schema": "public",
+ "values": [
+ "ALL",
+ "ANY"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0017_snapshot.json b/packages/database/drizzle/meta/0017_snapshot.json
new file mode 100644
index 0000000..7b8b4c8
--- /dev/null
+++ b/packages/database/drizzle/meta/0017_snapshot.json
@@ -0,0 +1,1883 @@
+{
+ "id": "ed9f57a7-f288-461f-a2de-47a6a344a5e3",
+ "prevId": "9d96b179-748d-4417-b071-1cc351642104",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "question_layout": {
+ "name": "question_layout",
+ "type": "question_layout",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'AUTO'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_conditions": {
+ "name": "form_logic_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_conditions_rule_idx": {
+ "name": "form_logic_conditions_rule_idx",
+ "columns": [
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_conditions_field_idx": {
+ "name": "form_logic_conditions_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_conditions_rule_id_form_logic_rules_id_fk": {
+ "name": "form_logic_conditions_rule_id_form_logic_rules_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_logic_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_conditions_field_id_form_fields_id_fk": {
+ "name": "form_logic_conditions_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_conditions_value_matches_operator": {
+ "name": "form_logic_conditions_value_matches_operator",
+ "value": "(\n (\"form_logic_conditions\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NULL)\n OR (\"form_logic_conditions\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match": {
+ "name": "match",
+ "type": "logic_match",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ALL'"
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_action": {
+ "name": "else_action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_field_id": {
+ "name": "else_target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_segment_id": {
+ "name": "else_target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_else_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "else_target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_else_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "else_target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_else_target_matches_action": {
+ "name": "form_logic_rules_else_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"else_target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"else_target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_field_id\" IS NULL)\n OR (\n (\"form_logic_rules\".\"else_action\" IS NULL OR \"form_logic_rules\".\"else_action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))\n AND \"form_logic_rules\".\"else_target_field_id\" IS NULL\n AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL\n )\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "(\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\")\n AND (\"form_logic_rules\".\"else_target_field_id\" IS NULL OR \"form_logic_rules\".\"else_target_field_id\" <> \"form_logic_rules\".\"field_id\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_visitor_idx": {
+ "name": "form_submissions_form_visitor_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visitor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"visitor_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.question_layout": {
+ "name": "question_layout",
+ "schema": "public",
+ "values": [
+ "AUTO",
+ "ONE_PER_PAGE",
+ "SEGMENT_PER_PAGE",
+ "ALL_AT_ONCE"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE"
+ ]
+ },
+ "public.logic_match": {
+ "name": "logic_match",
+ "schema": "public",
+ "values": [
+ "ALL",
+ "ANY"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0018_snapshot.json b/packages/database/drizzle/meta/0018_snapshot.json
new file mode 100644
index 0000000..868600d
--- /dev/null
+++ b/packages/database/drizzle/meta/0018_snapshot.json
@@ -0,0 +1,1999 @@
+{
+ "id": "ab8784f7-9d7d-4a3c-a726-6253a597fc5b",
+ "prevId": "ed9f57a7-f288-461f-a2de-47a6a344a5e3",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "question_layout": {
+ "name": "question_layout",
+ "type": "question_layout",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'AUTO'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_conditions": {
+ "name": "form_logic_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_conditions_rule_idx": {
+ "name": "form_logic_conditions_rule_idx",
+ "columns": [
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_conditions_field_idx": {
+ "name": "form_logic_conditions_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_conditions_rule_id_form_logic_rules_id_fk": {
+ "name": "form_logic_conditions_rule_id_form_logic_rules_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_logic_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_conditions_field_id_form_fields_id_fk": {
+ "name": "form_logic_conditions_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_conditions_value_matches_operator": {
+ "name": "form_logic_conditions_value_matches_operator",
+ "value": "(\n (\"form_logic_conditions\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NULL)\n OR (\"form_logic_conditions\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match": {
+ "name": "match",
+ "type": "logic_match",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ALL'"
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_action": {
+ "name": "else_action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_field_id": {
+ "name": "else_target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_segment_id": {
+ "name": "else_target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_else_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "else_target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_else_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "else_target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_else_target_matches_action": {
+ "name": "form_logic_rules_else_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"else_target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"else_target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_field_id\" IS NULL)\n OR (\n (\"form_logic_rules\".\"else_action\" IS NULL OR \"form_logic_rules\".\"else_action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))\n AND \"form_logic_rules\".\"else_target_field_id\" IS NULL\n AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL\n )\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "(\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\")\n AND (\"form_logic_rules\".\"else_target_field_id\" IS NULL OR \"form_logic_rules\".\"else_target_field_id\" <> \"form_logic_rules\".\"field_id\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_visitor_idx": {
+ "name": "form_submissions_form_visitor_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "visitor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"visitor_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_drafts": {
+ "name": "form_drafts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_path": {
+ "name": "page_path",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_drafts_user_form_idx": {
+ "name": "form_drafts_user_form_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_drafts_form_id_forms_id_fk": {
+ "name": "form_drafts_form_id_forms_id_fk",
+ "tableFrom": "form_drafts",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_drafts_user_id_users_id_fk": {
+ "name": "form_drafts_user_id_users_id_fk",
+ "tableFrom": "form_drafts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_drafts_form_id_user_id_unique": {
+ "name": "form_drafts_form_id_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.question_layout": {
+ "name": "question_layout",
+ "schema": "public",
+ "values": [
+ "AUTO",
+ "ONE_PER_PAGE",
+ "SEGMENT_PER_PAGE",
+ "ALL_AT_ONCE"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE"
+ ]
+ },
+ "public.logic_match": {
+ "name": "logic_match",
+ "schema": "public",
+ "values": [
+ "ALL",
+ "ANY"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/0019_snapshot.json b/packages/database/drizzle/meta/0019_snapshot.json
new file mode 100644
index 0000000..802a187
--- /dev/null
+++ b/packages/database/drizzle/meta/0019_snapshot.json
@@ -0,0 +1,2056 @@
+{
+ "id": "b0751241-0d34-4282-a7a6-c99812ff63a5",
+ "prevId": "ab8784f7-9d7d-4a3c-a726-6253a597fc5b",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_users_id_fk": {
+ "name": "account_user_id_users_id_fk",
+ "tableFrom": "account",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "sessions_user_id_idx": {
+ "name": "sessions_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "sessions_expires_at_idx": {
+ "name": "sessions_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.forms": {
+ "name": "forms",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_published": {
+ "name": "is_published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_archived": {
+ "name": "is_archived",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_open": {
+ "name": "is_open",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "question_layout": {
+ "name": "question_layout",
+ "type": "question_layout",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'AUTO'"
+ },
+ "require_sign_in": {
+ "name": "require_sign_in",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "collect_respondent_email": {
+ "name": "collect_respondent_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "one_response_per_respondent": {
+ "name": "one_response_per_respondent",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "allowed_email_domains": {
+ "name": "allowed_email_domains",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "thank_you_message": {
+ "name": "thank_you_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "forms_owner_created_idx": {
+ "name": "forms_owner_created_idx",
+ "columns": [
+ {
+ "expression": "owner_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "forms_owner_id_users_id_fk": {
+ "name": "forms_owner_id_users_id_fk",
+ "tableFrom": "forms",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "forms_slug_unique": {
+ "name": "forms_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_segments": {
+ "name": "form_segments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_segments_form_idx": {
+ "name": "form_segments_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_segments_form_id_forms_id_fk": {
+ "name": "form_segments_form_id_forms_id_fk",
+ "tableFrom": "form_segments",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_segments_form_id_index_unique": {
+ "name": "form_segments_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_fields": {
+ "name": "form_fields",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "segment_id": {
+ "name": "segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_key": {
+ "name": "label_key",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "placeholder": {
+ "name": "placeholder",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_required": {
+ "name": "is_required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "field_type_num",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_fields_form_idx": {
+ "name": "form_fields_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_fields_segment_idx": {
+ "name": "form_fields_segment_idx",
+ "columns": [
+ {
+ "expression": "segment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_fields_form_id_forms_id_fk": {
+ "name": "form_fields_form_id_forms_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_fields_segment_id_form_segments_id_fk": {
+ "name": "form_fields_segment_id_form_segments_id_fk",
+ "tableFrom": "form_fields",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_fields_form_id_index_unique": {
+ "name": "form_fields_form_id_index_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "index"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_logic_conditions": {
+ "name": "form_logic_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "rule_id": {
+ "name": "rule_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "operator": {
+ "name": "operator",
+ "type": "logic_operator",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_conditions_rule_idx": {
+ "name": "form_logic_conditions_rule_idx",
+ "columns": [
+ {
+ "expression": "rule_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_conditions_field_idx": {
+ "name": "form_logic_conditions_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_conditions_rule_id_form_logic_rules_id_fk": {
+ "name": "form_logic_conditions_rule_id_form_logic_rules_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_logic_rules",
+ "columnsFrom": [
+ "rule_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_conditions_field_id_form_fields_id_fk": {
+ "name": "form_logic_conditions_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_conditions",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_conditions_value_matches_operator": {
+ "name": "form_logic_conditions_value_matches_operator",
+ "value": "(\n (\"form_logic_conditions\".\"operator\" IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NULL)\n OR (\"form_logic_conditions\".\"operator\" NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND \"form_logic_conditions\".\"value\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_logic_rules": {
+ "name": "form_logic_rules",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match": {
+ "name": "match",
+ "type": "logic_match",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ALL'"
+ },
+ "action": {
+ "name": "action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_field_id": {
+ "name": "target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_segment_id": {
+ "name": "target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_action": {
+ "name": "else_action",
+ "type": "logic_action",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_field_id": {
+ "name": "else_target_field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "else_target_segment_id": {
+ "name": "else_target_segment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "index": {
+ "name": "index",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_logic_rules_form_idx": {
+ "name": "form_logic_rules_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_logic_rules_field_idx": {
+ "name": "form_logic_rules_field_idx",
+ "columns": [
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_logic_rules_form_id_forms_id_fk": {
+ "name": "form_logic_rules_form_id_forms_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_field_id_form_fields_id_fk": {
+ "name": "form_logic_rules_else_target_field_id_form_fields_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "else_target_field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_logic_rules_else_target_segment_id_form_segments_id_fk": {
+ "name": "form_logic_rules_else_target_segment_id_form_segments_id_fk",
+ "tableFrom": "form_logic_rules",
+ "tableTo": "form_segments",
+ "columnsFrom": [
+ "else_target_segment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "form_logic_rules_target_matches_action": {
+ "name": "form_logic_rules_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"target_field_id\" IS NULL)\n OR (\"form_logic_rules\".\"action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND \"form_logic_rules\".\"target_field_id\" IS NULL AND \"form_logic_rules\".\"target_segment_id\" IS NULL)\n )"
+ },
+ "form_logic_rules_else_target_matches_action": {
+ "name": "form_logic_rules_else_target_matches_action",
+ "value": "(\n (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_FIELD' AND \"form_logic_rules\".\"else_target_field_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL)\n OR (\"form_logic_rules\".\"else_action\" = 'JUMP_TO_SEGMENT' AND \"form_logic_rules\".\"else_target_segment_id\" IS NOT NULL AND \"form_logic_rules\".\"else_target_field_id\" IS NULL)\n OR (\n (\"form_logic_rules\".\"else_action\" IS NULL OR \"form_logic_rules\".\"else_action\" NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))\n AND \"form_logic_rules\".\"else_target_field_id\" IS NULL\n AND \"form_logic_rules\".\"else_target_segment_id\" IS NULL\n )\n )"
+ },
+ "form_logic_rules_no_self_jump": {
+ "name": "form_logic_rules_no_self_jump",
+ "value": "(\"form_logic_rules\".\"target_field_id\" IS NULL OR \"form_logic_rules\".\"target_field_id\" <> \"form_logic_rules\".\"field_id\")\n AND (\"form_logic_rules\".\"else_target_field_id\" IS NULL OR \"form_logic_rules\".\"else_target_field_id\" <> \"form_logic_rules\".\"field_id\")"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.form_submissions": {
+ "name": "form_submissions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visitor_id": {
+ "name": "visitor_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "respondent_user_id": {
+ "name": "respondent_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "respondent_email": {
+ "name": "respondent_email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "referrer": {
+ "name": "referrer",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_source": {
+ "name": "utm_source",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_medium": {
+ "name": "utm_medium",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "utm_campaign": {
+ "name": "utm_campaign",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "time_spent_ms": {
+ "name": "time_spent_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "varchar(50)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_submissions_form_created_idx": {
+ "name": "form_submissions_form_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_idempotency_idx": {
+ "name": "form_submissions_form_idempotency_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"form_submissions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_submissions_form_respondent_idx": {
+ "name": "form_submissions_form_respondent_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "respondent_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_submissions_form_id_forms_id_fk": {
+ "name": "form_submissions_form_id_forms_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_submissions_respondent_user_id_users_id_fk": {
+ "name": "form_submissions_respondent_user_id_users_id_fk",
+ "tableFrom": "form_submissions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "respondent_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_drafts": {
+ "name": "form_drafts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "values": {
+ "name": "values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "page_path": {
+ "name": "page_path",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_drafts_user_form_idx": {
+ "name": "form_drafts_user_form_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_drafts_form_id_forms_id_fk": {
+ "name": "form_drafts_form_id_forms_id_fk",
+ "tableFrom": "form_drafts",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_drafts_user_id_users_id_fk": {
+ "name": "form_drafts_user_id_users_id_fk",
+ "tableFrom": "form_drafts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "form_drafts_form_id_user_id_unique": {
+ "name": "form_drafts_form_id_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "form_id",
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_field_views": {
+ "name": "form_field_views",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "field_id": {
+ "name": "field_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_field_views_form_field_idx": {
+ "name": "form_field_views_form_field_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "field_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_field_views_created_idx": {
+ "name": "form_field_views_created_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_field_views_form_id_forms_id_fk": {
+ "name": "form_field_views_form_id_forms_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_field_views_field_id_form_fields_id_fk": {
+ "name": "form_field_views_field_id_form_fields_id_fk",
+ "tableFrom": "form_field_views",
+ "tableTo": "form_fields",
+ "columnsFrom": [
+ "field_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.form_collaborators": {
+ "name": "form_collaborators",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "form_id": {
+ "name": "form_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "collaborator_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "form_collaborators_form_idx": {
+ "name": "form_collaborators_form_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_user_idx": {
+ "name": "form_collaborators_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "form_collaborators_form_user_uniq_idx": {
+ "name": "form_collaborators_form_user_uniq_idx",
+ "columns": [
+ {
+ "expression": "form_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "form_collaborators_form_id_forms_id_fk": {
+ "name": "form_collaborators_form_id_forms_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "forms",
+ "columnsFrom": [
+ "form_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_user_id_users_id_fk": {
+ "name": "form_collaborators_user_id_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "form_collaborators_added_by_users_id_fk": {
+ "name": "form_collaborators_added_by_users_id_fk",
+ "tableFrom": "form_collaborators",
+ "tableTo": "users",
+ "columnsFrom": [
+ "added_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback": {
+ "name": "feedback",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "type": {
+ "name": "type",
+ "type": "feedback_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'feedback'"
+ },
+ "subject": {
+ "name": "subject",
+ "type": "varchar(120)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "feedback_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "priority": {
+ "name": "priority",
+ "type": "feedback_priority",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(255)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_url": {
+ "name": "page_url",
+ "type": "varchar(2048)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "feedback_status_created_idx": {
+ "name": "feedback_status_created_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_user_created_idx": {
+ "name": "feedback_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_email_created_idx": {
+ "name": "feedback_email_created_idx",
+ "columns": [
+ {
+ "expression": "email",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_user_id_users_id_fk": {
+ "name": "feedback_user_id_users_id_fk",
+ "tableFrom": "feedback",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.question_layout": {
+ "name": "question_layout",
+ "schema": "public",
+ "values": [
+ "AUTO",
+ "ONE_PER_PAGE",
+ "SEGMENT_PER_PAGE",
+ "ALL_AT_ONCE"
+ ]
+ },
+ "public.field_type_num": {
+ "name": "field_type_num",
+ "schema": "public",
+ "values": [
+ "TEXT",
+ "TEXTAREA",
+ "NUMBER",
+ "EMAIL",
+ "PHONE",
+ "URL",
+ "SELECT",
+ "RADIO",
+ "CHECKBOX",
+ "DATE",
+ "RATING",
+ "FILE_UPLOAD",
+ "TIME",
+ "DATETIME",
+ "SLIDER",
+ "TOGGLE"
+ ]
+ },
+ "public.logic_action": {
+ "name": "logic_action",
+ "schema": "public",
+ "values": [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE"
+ ]
+ },
+ "public.logic_match": {
+ "name": "logic_match",
+ "schema": "public",
+ "values": [
+ "ALL",
+ "ANY"
+ ]
+ },
+ "public.logic_operator": {
+ "name": "logic_operator",
+ "schema": "public",
+ "values": [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH"
+ ]
+ },
+ "public.collaborator_role": {
+ "name": "collaborator_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.feedback_priority": {
+ "name": "feedback_priority",
+ "schema": "public",
+ "values": [
+ "low",
+ "medium",
+ "high"
+ ]
+ },
+ "public.feedback_status": {
+ "name": "feedback_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "resolved",
+ "closed"
+ ]
+ },
+ "public.feedback_type": {
+ "name": "feedback_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "feedback",
+ "complaint",
+ "feature_request"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json
index 3f53da7..93b7e8f 100644
--- a/packages/database/drizzle/meta/_journal.json
+++ b/packages/database/drizzle/meta/_journal.json
@@ -92,6 +92,55 @@
"when": 1785450312351,
"tag": "0012_flimsy_vision",
"breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1785525377471,
+ "tag": "0013_big_bruce_banner",
+ "breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "7",
+ "when": 1785528829178,
+ "tag": "0014_plain_norman_osborn",
+ "breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "when": 1785528904334,
+ "tag": "0015_good_captain_marvel",
+ "breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "7",
+ "when": 1785530772378,
+ "tag": "0016_spooky_the_fallen",
+ "breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "7",
+ "when": 1785532560477,
+ "tag": "0017_nebulous_hellcat",
+ "breakpoints": true
+ },
+ {
+ "idx": 18,
+ "version": "7",
+ "when": 1785534275413,
+ "tag": "0018_perfect_molten_man",
+ "breakpoints": true
+ },
+ {
+ "idx": 19,
+ "version": "7",
+ "when": 1785535481933,
+ "tag": "0019_dashing_veda",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/database/index.ts b/packages/database/index.ts
index 1c130eb..55095e1 100644
--- a/packages/database/index.ts
+++ b/packages/database/index.ts
@@ -6,26 +6,38 @@ import { env } from "./env";
/**
* Shared connection pool.
*
- * Why explicit values (instead of node-postgres defaults):
- * - `max: 10` — Neon's free tier caps total active connections per
- * project at ~100. With a single API instance plus
- * Better Auth's own pool, 10 leaves plenty of headroom
- * and prevents one runaway request from starving auth.
- * Raise this once you're on a paid Neon plan or you'll
- * see "remaining connection slots" errors from Postgres.
- * - `idleTimeoutMillis: 5min` — Neon's TCP layer closes idle sockets
- * after ~5 minutes anyway, so we keep the pool a hair
- * under that. The previous 30s value was making every
- * request on a quiet endpoint pay the full TLS+
- * handshake cost (~250–700ms to us-east). Pairs with
- * `keepAlive: true` so the sockets we hold stay live.
- * - `keepAlive: true` — turns on TCP keep-alive so idle sockets aren't
- * silently dropped by Neon's load balancer; without
- * this we'd hit `read ETIMEDOUT` and reconnect on the
- * next request, eating ~300ms.
+ * Locally this points at the docker-compose Postgres (see
+ * `docker-compose.yml`, host port 5434 — 5432/5433 are taken by other
+ * projects on the dev machine). Bring it up with `pnpm db:up`.
+ *
+ * The values below are sized for the worst case — a *managed remote*
+ * Postgres, where every new connection costs a TLS handshake across a
+ * WAN. They're deliberately kept for local Docker too: against loopback
+ * they're simply cheap no-ops, so dev and prod share one code path
+ * instead of diverging on connection behaviour.
+ *
+ * - `max: 10` — managed tiers commonly cap active connections per
+ * project around ~100. With a single API instance plus
+ * Better Auth sharing this pool, 10 leaves headroom and
+ * stops one runaway request from starving auth. Local
+ * Postgres defaults to `max_connections = 100`, so 10
+ * is comfortable there as well. If you raise it, check
+ * the ceiling on whichever server you're pointed at or
+ * you'll see "remaining connection slots" errors.
+ * - `idleTimeoutMillis: 5min` — managed providers tend to close idle
+ * sockets around the 5 minute mark, so we sit a hair
+ * under it. The previous 30s value made every request
+ * on a quiet endpoint pay a fresh handshake
+ * (~250-700ms cross-region). Pairs with `keepAlive`.
+ * - `keepAlive: true` — TCP keep-alive, so idle sockets aren't silently
+ * dropped by an intermediary load balancer. Without it
+ * we'd hit `read ETIMEDOUT` and reconnect on the next
+ * request. Harmless against local Docker.
* - `connectionTimeoutMillis: 10s` — fail fast if the database isn't
- * reachable instead of hanging the
- * request indefinitely.
+ * reachable instead of hanging the request forever.
+ * This is the one you'll notice locally: if you forgot
+ * `pnpm db:up`, you get a clear timeout in 10s rather
+ * than a stuck request.
*
* Shared by the app queries and Better Auth's drizzle adapter.
*/
@@ -46,8 +58,10 @@ pool.on("error", (err) => {
// Warm a small set of connections at boot so the very first burst of
// concurrent queries (e.g. the dashboard fires 4 queries in parallel)
-// doesn't pay 4 sequential TLS handshakes to Neon. We don't try to fill
-// the whole pool — just enough for the typical first request.
+// doesn't pay 4 sequential TLS handshakes against a remote server. We
+// don't try to fill the whole pool — just enough for the typical first
+// request. Against local Docker this is near-instant and effectively
+// free, so it's left on to keep dev and prod behaviour identical.
const WARM_CONNECTIONS = 4;
Promise.all(Array.from({ length: WARM_CONNECTIONS }, () => pool.connect()))
.then((clients) => clients.forEach((c) => c.release()))
diff --git a/packages/database/models/auth.ts b/packages/database/models/auth.ts
index e337d68..73060ba 100644
--- a/packages/database/models/auth.ts
+++ b/packages/database/models/auth.ts
@@ -1,11 +1,4 @@
-import { pgTable, varchar, timestamp, boolean, text, pgEnum, index } from "drizzle-orm/pg-core";
-
-export const subscriptionTypeEnum = pgEnum("subscription_type", [
- "Free",
- "Pro",
- "Pro+",
- "Business",
-]);
+import { pgTable, varchar, timestamp, boolean, text, index } from "drizzle-orm/pg-core";
export const usersTable = pgTable("users", {
id: text("id").primaryKey(),
@@ -14,7 +7,6 @@ export const usersTable = pgTable("users", {
email: varchar("email", { length: 255 }).notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
- plan: subscriptionTypeEnum("plan").notNull().default("Free"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
diff --git a/packages/database/models/form-draft.ts b/packages/database/models/form-draft.ts
new file mode 100644
index 0000000..715f0a6
--- /dev/null
+++ b/packages/database/models/form-draft.ts
@@ -0,0 +1,67 @@
+import { pgTable, uuid, text, jsonb, timestamp, unique, index } from "drizzle-orm/pg-core";
+import { formsTable } from "./form";
+import { usersTable } from "./auth";
+
+/**
+ * A partially-filled form, saved so a signed-in respondent can come back.
+ *
+ * The case this exists for: a fifty-question form, ten answered, and the person
+ * needs to stop. Without somewhere to put those ten answers the only options
+ * are submit something incomplete or lose it, and both are worse than pausing.
+ *
+ * Separate from `form_submissions` rather than a `status` column on it, for
+ * three reasons that all point the same way:
+ * · A submission is a fact — it happened, it counts, analytics read it. A
+ * draft is a scratchpad that will be overwritten repeatedly and then
+ * deleted. Mixing them means every analytics query has to remember to
+ * filter, and the one that forgets silently reports abandoned drafts as
+ * responses.
+ * · Submissions are deliberately anonymous (a `visitor_id`, not a user).
+ * A draft has to be tied to an account, because that account is how we
+ * recognise the person on their return.
+ * · The dedupe indexes on submissions would fight repeated draft upserts.
+ *
+ * Requires an account by design. An anonymous visitor has no identity that
+ * survives a new browser session, so a "draft" for them would be indistinguish-
+ * able from a lost one — localStorage on the client is the honest tool there.
+ */
+export const formDraftsTable = pgTable(
+ "form_drafts",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+
+ formId: uuid("form_id")
+ .references(() => formsTable.id, { onDelete: "cascade" })
+ .notNull(),
+
+ userId: text("user_id")
+ .references(() => usersTable.id, { onDelete: "cascade" })
+ .notNull(),
+
+ // Answers so far, keyed by field id. Same jsonb shape the renderer holds in
+ // state, so restoring is an assignment rather than a translation.
+ values: jsonb("values").notNull(),
+
+ // The pages already visited, so the respondent resumes where they stopped
+ // rather than at question one. Stored as the page-index path the renderer
+ // walks; a plain "page number" wouldn't survive branching, where two people
+ // on their third page can be on different pages.
+ pagePath: jsonb("page_path"),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => new Date())
+ .notNull(),
+ },
+ (table) => {
+ return {
+ // One draft per person per form. This is also the conflict target the
+ // upsert relies on, so repeated autosaves rewrite a single row instead
+ // of accumulating one per keystroke burst.
+ uniqueFormAndUser: unique().on(table.formId, table.userId),
+ // "Do I have a draft for this form?" on every form open.
+ userFormIdx: index("form_drafts_user_form_idx").on(table.userId, table.formId),
+ };
+ },
+);
diff --git a/packages/database/models/form-field.ts b/packages/database/models/form-field.ts
index 78ccc77..e8040b7 100644
--- a/packages/database/models/form-field.ts
+++ b/packages/database/models/form-field.ts
@@ -15,6 +15,7 @@ import {
} from "drizzle-orm/pg-core";
import { usersTable } from "./auth";
import { formsTable } from "./form";
+import { formSegmentsTable } from "./form-segment";
export const fieldTypeEnum = pgEnum("field_type_num", [
"TEXT",
@@ -45,6 +46,22 @@ export const formFieldsTable = pgTable(
})
.notNull(),
+ // Which segment (page) this question belongs to.
+ //
+ // Nullable on purpose. NULL means "the implicit first segment", which is
+ // exactly how every form behaved before segments existed — so forms
+ // created earlier keep working with no backfill and no behaviour change.
+ // The moment an editor adds a real segment, the service assigns these
+ // loose fields to it (see `ensureDefaultSegment`), and from then on the
+ // form is explicitly segmented.
+ //
+ // ON DELETE SET NULL, not CASCADE: deleting a segment must not silently
+ // delete the editor's questions. They fall back to the implicit first
+ // segment where they stay visible and recoverable.
+ segmentId: uuid("segment_id").references(() => formSegmentsTable.id, {
+ onDelete: "set null",
+ }),
+
label: varchar("label", { length: 255 }).notNull(),
labelKey: varchar("label_key", { length: 255 }).notNull(),
@@ -71,9 +88,16 @@ export const formFieldsTable = pgTable(
},
(table) => {
return {
+ // Ordering stays scoped to the FORM, not the segment. Effective
+ // question order is (segment.index, field.index), so a field's index
+ // only ever has to be unique within the form for the sort to be
+ // stable — and keeping the constraint here means moving a field
+ // between segments is a one-column update that can't collide.
uniqueFormIdAndIndex: unique().on(table.formId, table.index),
// Hot path for listing a form's fields ordered by index
formIdx: index("form_fields_form_idx").on(table.formId),
+ // Grouping a form's fields by segment when rendering / reordering.
+ segmentIdx: index("form_fields_segment_idx").on(table.segmentId, table.index),
};
},
);
diff --git a/packages/database/models/form-logic.ts b/packages/database/models/form-logic.ts
new file mode 100644
index 0000000..74cf74f
--- /dev/null
+++ b/packages/database/models/form-logic.ts
@@ -0,0 +1,262 @@
+import {
+ pgTable,
+ uuid,
+ timestamp,
+ integer,
+ numeric,
+ jsonb,
+ pgEnum,
+ index,
+ check,
+} from "drizzle-orm/pg-core";
+import { sql } from "drizzle-orm";
+import { formsTable } from "./form";
+import { formFieldsTable } from "./form-field";
+import { formSegmentsTable } from "./form-segment";
+
+/**
+ * How a single condition compares a respondent's answer against `value`.
+ *
+ * IS_EMPTY / IS_NOT_EMPTY ignore `value` entirely, which is why the column is
+ * nullable. IS_ANY_OF / IS_NONE_OF take an array and are the operators an
+ * author actually wants for "sent to the enterprise branch if they picked any
+ * of these three plans" — expressing that with EQUALS means three near-
+ * duplicate rules that have to be kept in sync.
+ */
+export const logicOperatorEnum = pgEnum("logic_operator", [
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH",
+]);
+
+/** Whether every condition on a rule has to hold, or just one of them. */
+export const logicMatchEnum = pgEnum("logic_match", ["ALL", "ANY"]);
+
+/**
+ * Where a branch sends the respondent.
+ *
+ * JUMP_TO_FIELD is "ask that question next". JUMP_TO_SEGMENT is "send them to
+ * that page". SUBMIT ends the form early, which dead-end branches need —
+ * without it a "not interested" answer has to be padded with filler questions
+ * to reach the end. CONTINUE means "carry on in normal order" and exists so
+ * the *else* side of a branch can be stated explicitly rather than left to be
+ * inferred from its absence.
+ */
+export const logicActionEnum = pgEnum("logic_action", [
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE",
+]);
+
+/**
+ * One branch: "after question X, if then go here, otherwise go
+ * there".
+ *
+ * The conditions live in `form_logic_conditions`, one row each, so a branch can
+ * weigh several answers at once ("plan is Pro AND team size is over 50").
+ * They're a child table rather than a jsonb array on this row for one reason
+ * that matters: each condition names a question, and that reference needs a
+ * real foreign key. Deleting a question then removes the conditions that read
+ * it, instead of leaving a rule quietly testing an id that no longer exists.
+ *
+ * Three field references, doing three different jobs, which is worth keeping
+ * straight:
+ * · `field_id` — WHEN to evaluate. The rule is checked
+ * after this question is answered.
+ * · `form_logic_conditions.field_id` — WHAT to read. Any question in the
+ * form, not just the trigger.
+ * · `target_field_id` / `else_...` — WHERE to go.
+ *
+ * Evaluation is first-decision-wins in `index` order. A rule whose conditions
+ * hold takes its `action`. A rule whose conditions fail takes `else_action` if
+ * one is set, and is otherwise skipped so the next rule gets a turn. That
+ * distinction is the whole point of a nullable else: a rule with no else is a
+ * guard clause, a rule with one is a fork.
+ */
+export const formLogicRulesTable = pgTable(
+ "form_logic_rules",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+
+ // Denormalised from `form_fields.form_id` so the public form read can
+ // fetch every rule for a form in one indexed query, without joining
+ // through fields just to scope by form.
+ formId: uuid("form_id")
+ .references(() => formsTable.id, {
+ onDelete: "cascade",
+ })
+ .notNull(),
+
+ // The question after which this rule is evaluated.
+ fieldId: uuid("field_id")
+ .references(() => formFieldsTable.id, {
+ onDelete: "cascade",
+ })
+ .notNull(),
+
+ match: logicMatchEnum("match").notNull().default("ALL"),
+
+ /* ── the "then" side ── */
+ action: logicActionEnum("action").notNull(),
+ targetFieldId: uuid("target_field_id").references(() => formFieldsTable.id, {
+ onDelete: "cascade",
+ }),
+ targetSegmentId: uuid("target_segment_id").references(() => formSegmentsTable.id, {
+ onDelete: "cascade",
+ }),
+
+ /* ── the "otherwise" side. NULL means "no opinion, try the next rule". ── */
+ elseAction: logicActionEnum("else_action"),
+ elseTargetFieldId: uuid("else_target_field_id").references(() => formFieldsTable.id, {
+ onDelete: "cascade",
+ }),
+ elseTargetSegmentId: uuid("else_target_segment_id").references(() => formSegmentsTable.id, {
+ onDelete: "cascade",
+ }),
+
+ index: numeric("index", { scale: 2 }).notNull(),
+
+ // Optimistic-lock counter (see comment on forms.version)
+ version: integer("version").notNull().default(0),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => new Date())
+ .notNull(),
+ },
+ (table) => {
+ return {
+ // Public form read: every rule for a form, ordered by index.
+ formIdx: index("form_logic_rules_form_idx").on(table.formId),
+ // Builder: the rules triggered by one selected question.
+ fieldIdx: index("form_logic_rules_field_idx").on(table.fieldId, table.index),
+
+ // A branch's target has to match its action, enforced here and not only
+ // in Zod. A JUMP_TO_SEGMENT with a null target_segment_id is not a
+ // harmless bad row: the resolver would decide the branch was taken, fail
+ // to resolve a destination, and drop the respondent mid-form. The
+ // database is the only place that can rule that out whatever writes it.
+ /* Both checks are phrased as "not one of the jump actions" rather than
+ * "is SUBMIT or CONTINUE", and that is not a style choice. Postgres
+ * refuses to evaluate a value added by ALTER TYPE ... ADD VALUE until
+ * the adding transaction commits, and drizzle applies every pending
+ * migration inside ONE transaction — so a constraint naming 'CONTINUE'
+ * in the same migration that introduces it fails with
+ * `unsafe use of new value` (55P04), and no amount of splitting the
+ * files avoids it. Naming only the long-standing values sidesteps the
+ * problem entirely, and has the side benefit that a future action which
+ * carries no target is covered without touching these constraints. */
+ targetMatchesAction: check(
+ "form_logic_rules_target_matches_action",
+ sql`(
+ (${table.action} = 'JUMP_TO_FIELD' AND ${table.targetFieldId} IS NOT NULL AND ${table.targetSegmentId} IS NULL)
+ OR (${table.action} = 'JUMP_TO_SEGMENT' AND ${table.targetSegmentId} IS NOT NULL AND ${table.targetFieldId} IS NULL)
+ OR (${table.action} NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT') AND ${table.targetFieldId} IS NULL AND ${table.targetSegmentId} IS NULL)
+ )`,
+ ),
+
+ // Same again for the else side, where NULL additionally means "no
+ // opinion" and must leave both targets empty.
+ elseTargetMatchesAction: check(
+ "form_logic_rules_else_target_matches_action",
+ sql`(
+ (${table.elseAction} = 'JUMP_TO_FIELD' AND ${table.elseTargetFieldId} IS NOT NULL AND ${table.elseTargetSegmentId} IS NULL)
+ OR (${table.elseAction} = 'JUMP_TO_SEGMENT' AND ${table.elseTargetSegmentId} IS NOT NULL AND ${table.elseTargetFieldId} IS NULL)
+ OR (
+ (${table.elseAction} IS NULL OR ${table.elseAction} NOT IN ('JUMP_TO_FIELD', 'JUMP_TO_SEGMENT'))
+ AND ${table.elseTargetFieldId} IS NULL
+ AND ${table.elseTargetSegmentId} IS NULL
+ )
+ )`,
+ ),
+
+ // Neither side may point back at the question that triggered the rule —
+ // that's an immediate loop for the respondent. Longer cycles can't be
+ // caught by a row-level constraint (the resolver carries a visited-set
+ // guard for those), but the one-hop case is free to rule out here.
+ noSelfJump: check(
+ "form_logic_rules_no_self_jump",
+ sql`(${table.targetFieldId} IS NULL OR ${table.targetFieldId} <> ${table.fieldId})
+ AND (${table.elseTargetFieldId} IS NULL OR ${table.elseTargetFieldId} <> ${table.fieldId})`,
+ ),
+ };
+ },
+);
+
+/**
+ * One test inside a branch: "the answer to question F satisfies
+ * operator/value".
+ *
+ * Rows are replaced as a set whenever a rule is saved, so `index` only decides
+ * display order and is deliberately not unique — these are never drag-ordered
+ * across a shared space, and a unique constraint would add save races for no
+ * benefit.
+ */
+export const formLogicConditionsTable = pgTable(
+ "form_logic_conditions",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+
+ ruleId: uuid("rule_id")
+ .references(() => formLogicRulesTable.id, {
+ onDelete: "cascade",
+ })
+ .notNull(),
+
+ // The question whose answer this condition reads. Not necessarily the
+ // rule's trigger question — that's what lets a branch weigh an answer
+ // given several questions ago.
+ fieldId: uuid("field_id")
+ .references(() => formFieldsTable.id, {
+ onDelete: "cascade",
+ })
+ .notNull(),
+
+ operator: logicOperatorEnum("operator").notNull(),
+
+ // Comparison operand. jsonb because answers are already jsonb
+ // (`form_submissions.values`) and may be a string, number, boolean, or an
+ // array of choices — storing the operand the same way avoids
+ // parse-and-guess at comparison time.
+ value: jsonb("value"),
+
+ index: numeric("index", { scale: 2 }).notNull(),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => new Date())
+ .notNull(),
+ },
+ (table) => {
+ return {
+ // Loading a rule's conditions in order.
+ ruleIdx: index("form_logic_conditions_rule_idx").on(table.ruleId, table.index),
+ // Finding the conditions that read a given question, for impact checks
+ // when that question is edited or removed.
+ fieldIdx: index("form_logic_conditions_field_idx").on(table.fieldId),
+
+ // Operators that compare against an operand need one; the two emptiness
+ // checks must not carry a stale value implying the comparison does
+ // something it doesn't.
+ valueMatchesOperator: check(
+ "form_logic_conditions_value_matches_operator",
+ sql`(
+ (${table.operator} IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND ${table.value} IS NULL)
+ OR (${table.operator} NOT IN ('IS_EMPTY', 'IS_NOT_EMPTY') AND ${table.value} IS NOT NULL)
+ )`,
+ ),
+ };
+ },
+);
diff --git a/packages/database/models/form-segment.ts b/packages/database/models/form-segment.ts
new file mode 100644
index 0000000..e532414
--- /dev/null
+++ b/packages/database/models/form-segment.ts
@@ -0,0 +1,62 @@
+import {
+ pgTable,
+ uuid,
+ varchar,
+ timestamp,
+ text,
+ integer,
+ numeric,
+ unique,
+ index,
+} from "drizzle-orm/pg-core";
+import { formsTable } from "./form";
+
+/**
+ * A segment is an ordered group of questions inside a form — "page 1",
+ * "page 2", etc. Respondents move through a form segment by segment, and
+ * branching rules (see `form-logic.ts`) can route them to a segment other
+ * than the next one in order.
+ *
+ * Why a table rather than a column on `form_fields`: a segment carries its
+ * own title/description that render above its questions, it has to exist
+ * before any field is assigned to it, and branch rules need a stable
+ * target id to point at. A plain `segment_number` integer on the field
+ * couldn't express any of that, and renumbering on reorder would fight the
+ * fractional-index scheme fields already use.
+ *
+ * Ordering mirrors `form_fields.index` exactly: a fractional `numeric` with
+ * UNIQUE(form_id, index), so inserting a segment between two others is a
+ * single-row write. See `apps/web/lib/fractional-index.ts`.
+ */
+export const formSegmentsTable = pgTable(
+ "form_segments",
+ {
+ id: uuid("id").defaultRandom().primaryKey(),
+ formId: uuid("form_id")
+ .references(() => formsTable.id, {
+ onDelete: "cascade",
+ })
+ .notNull(),
+
+ title: varchar("title", { length: 255 }).notNull(),
+ description: text("description"),
+
+ index: numeric("index", { scale: 2 }).notNull(),
+
+ // Optimistic-lock counter (see comment on forms.version)
+ version: integer("version").notNull().default(0),
+
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at")
+ .defaultNow()
+ .$onUpdate(() => new Date())
+ .notNull(),
+ },
+ (table) => {
+ return {
+ uniqueFormIdAndIndex: unique().on(table.formId, table.index),
+ // Hot path for listing a form's segments ordered by index
+ formIdx: index("form_segments_form_idx").on(table.formId),
+ };
+ },
+);
diff --git a/packages/database/models/form-submission.ts b/packages/database/models/form-submission.ts
index a0c5ed5..1421587 100644
--- a/packages/database/models/form-submission.ts
+++ b/packages/database/models/form-submission.ts
@@ -11,6 +11,7 @@ import {
} from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import { formsTable } from "./form";
+import { usersTable } from "./auth";
export interface FormSubmissionValue {
formFieldId: string;
@@ -38,13 +39,31 @@ export const formSubmissionsTable = pgTable(
idempotencyKey: varchar("idempotency_key", { length: 64 }),
// Per-form anonymous visitor id (UUID, generated client-side and
- // persisted in localStorage as `cf_vid_`). When supplied,
- // (form_id, visitor_id) is unique so a returning visitor can't
- // submit the same form twice. We keep this nullable for
- // private-mode / no-storage clients — those just fall through to
- // the idempotency-key check.
+ // persisted in localStorage as `cf_vid_`).
+ //
+ // No longer a uniqueness key. It used to carry a hard one-per-browser
+ // lockout, which was both too strict (a shared computer locked out the
+ // second person) and too weak (a different browser defeated it). Limiting
+ // responses is now an opt-in tied to an account. This is kept because the
+ // returning-respondent metric is derived from it.
visitorId: varchar("visitor_id", { length: 64 }),
+ // The signed-in respondent, when the form required signing in. Nullable
+ // because a form that doesn't require it still accepts anonymous answers.
+ //
+ // ON DELETE SET NULL, not CASCADE: if the respondent later deletes their
+ // account, the response is still the form owner's data and still counts —
+ // it just becomes anonymous again.
+ respondentUserId: text("respondent_user_id").references(() => usersTable.id, {
+ onDelete: "set null",
+ }),
+
+ // Copied from the account at submit time when the form asks for it. Stored
+ // rather than joined so the response keeps the address it was submitted
+ // with, even if the respondent later changes their account email or
+ // deletes the account.
+ respondentEmail: varchar("respondent_email", { length: 255 }),
+
// Attribution & timing (collected by the public form page)
referrer: varchar("referrer", { length: 2048 }),
utmSource: varchar("utm_source", { length: 255 }),
@@ -67,12 +86,17 @@ export const formSubmissionsTable = pgTable(
formIdempotencyIdx: uniqueIndex("form_submissions_form_idempotency_idx")
.on(table.formId, table.idempotencyKey)
.where(sql`${table.idempotencyKey} IS NOT NULL`),
- // Partial unique index on (form_id, visitor_id). Enforces the
- // "one submission per visitor" rule at the database level so two
- // racing inserts can't both squeak through the application-level
- // check. NULL visitor_ids (no localStorage available) are exempt.
- formVisitorIdx: uniqueIndex("form_submissions_form_visitor_idx")
- .on(table.formId, table.visitorId)
- .where(sql`${table.visitorId} IS NOT NULL`),
+ // Non-unique on purpose.
+ //
+ // "One response per respondent" is a per-form setting, and a unique index
+ // can't be conditional on a column in another table — so the rule is
+ // enforced in the service, and this index is what makes that check cheap.
+ // The residual race (the same account submitting twice simultaneously) is
+ // narrow and its outcome is a duplicate row rather than corruption; the
+ // idempotency key already covers the common cause, double-clicks.
+ formRespondentIdx: index("form_submissions_form_respondent_idx").on(
+ table.formId,
+ table.respondentUserId,
+ ),
}),
);
diff --git a/packages/database/models/form.ts b/packages/database/models/form.ts
index e47a6df..313a3da 100644
--- a/packages/database/models/form.ts
+++ b/packages/database/models/form.ts
@@ -7,9 +7,34 @@ import {
text,
integer,
index,
+ pgEnum,
+ jsonb,
} from "drizzle-orm/pg-core";
import { usersTable } from "./auth";
+/**
+ * How many questions a respondent sees at once.
+ *
+ * AUTO is the default and the reason this isn't just a boolean: the right
+ * answer depends on the form's shape. A form that was never split into
+ * segments is a single train of questions and reads best one at a time; a form
+ * with segments has already been divided into pages by its author, and showing
+ * those pages is what they were for. AUTO follows that, so an author who adds a
+ * second segment gets page-per-segment without having to discover a setting.
+ *
+ * The three explicit values exist for when the author disagrees:
+ * ONE_PER_PAGE — one question at a time regardless of segments.
+ * SEGMENT_PER_PAGE — a page per segment. With no segments this is the whole
+ * form on one page, which is the honest reading.
+ * ALL_AT_ONCE — everything on one page, Google Forms style.
+ */
+export const questionLayoutEnum = pgEnum("question_layout", [
+ "AUTO",
+ "ONE_PER_PAGE",
+ "SEGMENT_PER_PAGE",
+ "ALL_AT_ONCE",
+]);
+
export const formsTable = pgTable(
"forms",
{
@@ -29,9 +54,45 @@ export const formsTable = pgTable(
isPublished: boolean("is_published").default(false).notNull(),
isArchived: boolean("is_archived").default(false).notNull(),
isOpen: boolean("is_open").default(true).notNull(),
- maxSubmissions: integer("max_submissions"),
expiresAt: timestamp("expires_at"),
+ questionLayout: questionLayoutEnum("question_layout").notNull().default("AUTO"),
+
+ /* ── Who may respond ─────────────────────────────────────────────────
+ *
+ * These four settle into a dependency the service enforces rather than
+ * the schema: `collect_respondent_email`, `one_response_per_respondent`
+ * and `allowed_email_domains` are all meaningless without a signed-in
+ * respondent, so each one implies `require_sign_in`. A CHECK constraint
+ * could express that, but it would reject an editor who ticks "collect
+ * email" before ticking "require sign-in" — so the service raises the
+ * flag instead of refusing the write. */
+
+ // Respondents must be signed in. Everything below depends on it, because
+ // an account is the only respondent identity that survives a cleared
+ // browser or a second device.
+ requireSignIn: boolean("require_sign_in").default(false).notNull(),
+
+ // Record the signed-in respondent's account email against the response.
+ // Taken from the session, never from anything the client sends.
+ collectRespondentEmail: boolean("collect_respondent_email").default(false).notNull(),
+
+ // One response per account. Multiple responses are the default: the old
+ // behaviour was a hard one-per-browser lockout, which punished shared
+ // computers and was defeated by a different device.
+ oneResponsePerRespondent: boolean("one_response_per_respondent").default(false).notNull(),
+
+ // Email domains permitted to respond, e.g. ["rishihood.edu.in"]. Matched
+ // as a suffix, so an entry covers its subdomains too — one entry lets in
+ // both alice@rishihood.edu.in and bob@nst.rishihood.edu.in, which is what
+ // "the same institution" means in practice. NULL or empty = open.
+ allowedEmailDomains: jsonb("allowed_email_domains").$type(),
+
+ // Shown on the thank-you screen underneath the app's own confirmation,
+ // not instead of it: the respondent still needs to know the submission
+ // landed, and the author's note is additional context on top of that.
+ thankYouMessage: text("thank_you_message"),
+
// Optimistic-lock counter — incremented on every mutating update.
// Concurrent writers compare-and-set against this; the loser gets a
// 409-style conflict instead of silently overwriting fresher data.
diff --git a/packages/database/schema.ts b/packages/database/schema.ts
index a829d4c..a8b3806 100644
--- a/packages/database/schema.ts
+++ b/packages/database/schema.ts
@@ -1,7 +1,10 @@
export * from "./models/auth";
export * from "./models/form";
+export * from "./models/form-segment";
export * from "./models/form-field";
+export * from "./models/form-logic";
export * from "./models/form-submission";
+export * from "./models/form-draft";
export * from "./models/form-field-view";
export * from "./models/form-collaborator";
export * from "./models/feedback";
diff --git a/packages/services/analytics/index.ts b/packages/services/analytics/index.ts
index f549636..fb83765 100644
--- a/packages/services/analytics/index.ts
+++ b/packages/services/analytics/index.ts
@@ -10,8 +10,8 @@ import {
type GetFormAnalyticsInputType,
getSubmissionsListInput,
type GetSubmissionsListInputType,
- getProAnalyticsInput,
- type GetProAnalyticsInputType,
+ getDetailedAnalyticsInput,
+ type GetDetailedAnalyticsInputType,
recordFieldAnswerInput,
type RecordFieldAnswerInputType,
} from "./model";
@@ -20,7 +20,7 @@ const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
class AnalyticsService {
/**
- * Returns all free-tier analytics for a single form in one call.
+ * Returns all summary analytics for a single form in one call.
* Ownership is verified before querying.
*/
public async getFormAnalytics(payload: GetFormAnalyticsInputType & { ownerId: string }) {
@@ -126,11 +126,11 @@ class AnalyticsService {
}
/**
- * Pro-tier analytics: per-question distributions, day-of-week breakdown,
+ * Detailed analytics: per-question distributions, day-of-week breakdown,
* 30/60/90d totals, and response velocity (first 24h after publish).
*/
- public async getProAnalytics(payload: GetProAnalyticsInputType & { ownerId: string }) {
- const { formId } = await getProAnalyticsInput.parseAsync(payload);
+ public async getDetailedAnalytics(payload: GetDetailedAnalyticsInputType & { ownerId: string }) {
+ const { formId } = await getDetailedAnalyticsInput.parseAsync(payload);
const { ownerId: userId } = payload;
await requireViewer(formId, userId);
diff --git a/packages/services/analytics/model.ts b/packages/services/analytics/model.ts
index 43e93aa..4757a8e 100644
--- a/packages/services/analytics/model.ts
+++ b/packages/services/analytics/model.ts
@@ -97,10 +97,10 @@ export type RecordFieldAnswerOutputType = z.infer;
+export type GetDetailedAnalyticsInputType = z.infer;
export const questionDistributionOutput = z.object({
fieldId: z.string().uuid(),
@@ -135,7 +135,7 @@ export const questionDistributionOutput = z.object({
textSamples: z.array(z.string()).optional(),
});
-export const getProAnalyticsOutput = z.object({
+export const getDetailedAnalyticsOutput = z.object({
dowBreakdown: z.array(z.object({ day: z.string(), count: z.number() })),
trend30d: z.number(),
trend60d: z.number(),
@@ -160,4 +160,4 @@ export const getProAnalyticsOutput = z.object({
// UTM source breakdown, also from form_submissions
utmSources: z.array(z.object({ source: z.string(), count: z.number() })),
});
-export type GetProAnalyticsOutputType = z.infer;
+export type GetDetailedAnalyticsOutputType = z.infer;
diff --git a/packages/services/form-draft/index.ts b/packages/services/form-draft/index.ts
new file mode 100644
index 0000000..0e10cdf
--- /dev/null
+++ b/packages/services/form-draft/index.ts
@@ -0,0 +1,99 @@
+import { db, eq, and } from "@repo/database";
+import { formDraftsTable } from "@repo/database/models/form-draft";
+import { formsTable } from "@repo/database/models/form";
+import {
+ saveDraftInput,
+ type SaveDraftInputType,
+ getDraftInput,
+ type GetDraftInputType,
+ deleteDraftInput,
+ type DeleteDraftInputType,
+} from "./model";
+
+class FormDraftService {
+ /**
+ * Save (or overwrite) the caller's draft for a form.
+ *
+ * Scoped to `userId` throughout, never taking an id from the input, so a
+ * draft can only ever be written to or read from the caller's own row.
+ *
+ * A single upsert on the (form_id, user_id) unique constraint rather than
+ * select-then-insert-or-update: autosave fires repeatedly while someone
+ * types, and the read-then-write version races itself into duplicate-key
+ * errors on the second keystroke burst.
+ */
+ public async saveDraft(payload: SaveDraftInputType & { userId: string }) {
+ const { formId, values, pagePath } = await saveDraftInput.parseAsync(payload);
+ const { userId } = payload;
+
+ // The form has to exist and be live. Without this a draft could be kept
+ // against a deleted or unpublished form, which would then be restored onto
+ // a form the respondent can no longer see.
+ const formRows = await db
+ .select({ id: formsTable.id, isPublished: formsTable.isPublished })
+ .from(formsTable)
+ .where(eq(formsTable.id, formId));
+
+ const form = formRows[0];
+ if (!form) throw new Error("Form not found");
+ if (!form.isPublished) throw new Error("Form is not published yet");
+
+ const rows = await db
+ .insert(formDraftsTable)
+ .values({ formId, userId, values, pagePath: pagePath ?? null })
+ .onConflictDoUpdate({
+ target: [formDraftsTable.formId, formDraftsTable.userId],
+ set: { values, pagePath: pagePath ?? null, updatedAt: new Date() },
+ })
+ .returning({ id: formDraftsTable.id, updatedAt: formDraftsTable.updatedAt });
+
+ const saved = rows[0];
+ if (!saved) throw new Error("Failed to save draft");
+
+ return { id: saved.id, updatedAt: saved.updatedAt.toISOString() };
+ }
+
+ public async getDraft(payload: GetDraftInputType & { userId: string }) {
+ const { formId } = await getDraftInput.parseAsync(payload);
+ const { userId } = payload;
+
+ const rows = await db
+ .select({
+ values: formDraftsTable.values,
+ pagePath: formDraftsTable.pagePath,
+ updatedAt: formDraftsTable.updatedAt,
+ })
+ .from(formDraftsTable)
+ .where(and(eq(formDraftsTable.formId, formId), eq(formDraftsTable.userId, userId)));
+
+ const draft = rows[0];
+ if (!draft) return null;
+
+ return {
+ values: (draft.values ?? {}) as Record,
+ pagePath: (draft.pagePath as number[] | null) ?? null,
+ updatedAt: draft.updatedAt.toISOString(),
+ };
+ }
+
+ /**
+ * Discard the caller's draft. Called on a successful submit, and when the
+ * respondent explicitly starts over.
+ *
+ * Deliberately idempotent: submit fires this once, but a retried request or a
+ * double-click would fire it again, and "there was nothing to delete" is not
+ * a failure worth surfacing on a form the person has already completed.
+ */
+ public async deleteDraft(payload: DeleteDraftInputType & { userId: string }) {
+ const { formId } = await deleteDraftInput.parseAsync(payload);
+ const { userId } = payload;
+
+ await db
+ .delete(formDraftsTable)
+ .where(and(eq(formDraftsTable.formId, formId), eq(formDraftsTable.userId, userId)));
+
+ return { success: true };
+ }
+}
+
+export default FormDraftService;
diff --git a/packages/services/form-draft/model.ts b/packages/services/form-draft/model.ts
new file mode 100644
index 0000000..47aa3c1
--- /dev/null
+++ b/packages/services/form-draft/model.ts
@@ -0,0 +1,46 @@
+import { z } from "zod";
+
+export const saveDraftInput = z.object({
+ formId: z.string().uuid().describe("Form being filled in"),
+ values: z.record(z.string(), z.any()).describe("Answers so far, keyed by field id"),
+ pagePath: z
+ .array(z.number().int().nonnegative())
+ .optional()
+ .describe("Pages visited so far, so the respondent resumes where they stopped"),
+});
+export type SaveDraftInputType = z.infer;
+
+export const saveDraftOutput = z.object({
+ id: z.string().uuid(),
+ updatedAt: z.string().describe("ISO-8601 timestamp of this save"),
+});
+export type SaveDraftOutputType = z.infer;
+
+export const getDraftInput = z.object({
+ formId: z.string().uuid().describe("Form being filled in"),
+});
+export type GetDraftInputType = z.infer;
+
+/**
+ * Nullable rather than a 404: "no draft" is the normal case on a first visit,
+ * and making the caller catch an error for it would mean the form page logs an
+ * error on almost every load.
+ */
+export const getDraftOutput = z
+ .object({
+ values: z.record(z.string(), z.any()),
+ pagePath: z.array(z.number().int()).nullable(),
+ updatedAt: z.string().describe("ISO-8601 timestamp of the last save"),
+ })
+ .nullable();
+export type GetDraftOutputType = z.infer;
+
+export const deleteDraftInput = z.object({
+ formId: z.string().uuid().describe("Form whose draft should be discarded"),
+});
+export type DeleteDraftInputType = z.infer;
+
+export const deleteDraftOutput = z.object({
+ success: z.boolean(),
+});
+export type DeleteDraftOutputType = z.infer;
diff --git a/packages/services/form-field/index.ts b/packages/services/form-field/index.ts
index 9f8c200..ebd33a1 100644
--- a/packages/services/form-field/index.ts
+++ b/packages/services/form-field/index.ts
@@ -1,5 +1,6 @@
import { db, eq, and, max } from "@repo/database";
import { formFieldsTable } from "@repo/database/models/form-field";
+import { formSegmentsTable } from "@repo/database/models/form-segment";
import { requireEditor } from "../form";
import {
createFormFieldInput,
@@ -26,9 +27,36 @@ class FormFieldService {
return next.toFixed(2);
}
+ /**
+ * A question's segment must belong to the same form as the question.
+ *
+ * The FK only guarantees the segment row exists, not that it's in scope —
+ * without this an editor could file a question under another form's
+ * segment, where it would be invisible in this form's builder but still
+ * ordered against it.
+ */
+ private async assertSegmentBelongsToForm(
+ formId: string,
+ segmentId?: string | null,
+ ): Promise {
+ if (!segmentId) return;
+
+ const rows = await db
+ .select({ formId: formSegmentsTable.formId })
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.id, segmentId));
+
+ const segment = rows[0];
+ if (!segment) throw new Error("Segment not found");
+ if (segment.formId !== formId) {
+ throw new Error("Segment belongs to a different form");
+ }
+ }
+
public async createFormField(payload: CreateFormFieldInputType & { userId: string }) {
const {
formId,
+ segmentId,
label,
placeholder,
isRequired,
@@ -39,6 +67,7 @@ class FormFieldService {
} = await createFormFieldInput.parseAsync(payload);
await requireEditor(formId, payload.userId);
+ await this.assertSegmentBelongsToForm(formId, segmentId);
const labelKey =
label
@@ -59,6 +88,7 @@ class FormFieldService {
.insert(formFieldsTable)
.values({
formId,
+ segmentId: segmentId || null,
label,
labelKey,
placeholder: placeholder || undefined,
@@ -86,6 +116,7 @@ class FormFieldService {
public async updateFormField(payload: UpdateFormFieldInputType & { userId: string }) {
const {
id,
+ segmentId,
label,
placeholder,
isRequired,
@@ -106,6 +137,7 @@ class FormFieldService {
}
await requireEditor(existingField.formId, payload.userId);
+ await this.assertSegmentBelongsToForm(existingField.formId, segmentId);
// Optimistic-lock check — if the caller supplied an expected version
// and the row has since moved past it, another client wrote first.
@@ -138,6 +170,9 @@ class FormFieldService {
const updateResult = await db
.update(formFieldsTable)
.set({
+ // `null` is meaningful here (move back to the implicit first
+ // segment), so this checks `undefined` rather than falsiness.
+ ...(segmentId !== undefined ? { segmentId } : {}),
...(label !== undefined ? { label } : {}),
...(newLabelKey !== undefined ? { labelKey: newLabelKey } : {}),
...(placeholder !== undefined ? { placeholder } : {}),
diff --git a/packages/services/form-field/model.ts b/packages/services/form-field/model.ts
index 3f91fe7..cb3390a 100644
--- a/packages/services/form-field/model.ts
+++ b/packages/services/form-field/model.ts
@@ -21,6 +21,12 @@ export const fieldTypeZodEnum = z.enum([
export const createFormFieldInput = z.object({
formId: z.string().uuid().describe("ID of the parent form"),
+ segmentId: z
+ .string()
+ .uuid()
+ .optional()
+ .nullable()
+ .describe("Segment this question belongs to; null means the implicit first segment"),
label: z.string().trim().max(255, "Label is too long"),
placeholder: z.string().trim().max(255).optional().nullable(),
isRequired: z.boolean().default(false),
@@ -42,6 +48,12 @@ export const createFormFieldInput = z.object({
export const updateFormFieldInput = z.object({
id: z.string().uuid().describe("ID of the field to update"),
+ segmentId: z
+ .string()
+ .uuid()
+ .optional()
+ .nullable()
+ .describe("Move the question to this segment; null moves it to the implicit first segment"),
label: z.string().trim().max(255).optional(),
placeholder: z.string().trim().max(255).optional().nullable(),
isRequired: z.boolean().optional(),
@@ -102,6 +114,20 @@ export const getFormFieldInput = z.object({
export const getFormFieldOutput = z.object({
id: z.string().uuid().describe("ID of the form field"),
formId: z.string().uuid().describe("ID of the parent form"),
+ /* Nullable but not optional, unlike the inputs above.
+ *
+ * On the way in, an absent `segmentId` means "don't change it". On the way out
+ * there is no such thing: the row always has a value, even if that value is
+ * null. Making the property required is what stops a client building a
+ * field-shaped object and forgetting the segment — which is exactly how a
+ * question added from the outline came to land outside every segment, where
+ * the segment filter hid it and the editor saw an Add button that appeared to
+ * do nothing. The type system can catch that; a reviewer reliably can't. */
+ segmentId: z
+ .string()
+ .uuid()
+ .nullable()
+ .describe("Segment this question belongs to; null means the implicit first segment"),
label: z.string().describe("Label of the field"),
labelKey: z.string().describe("Immutable slug key of the field"),
placeholder: z.string().nullable().optional().describe("Placeholder of the field"),
diff --git a/packages/services/form-logic/index.ts b/packages/services/form-logic/index.ts
new file mode 100644
index 0000000..0e69f1a
--- /dev/null
+++ b/packages/services/form-logic/index.ts
@@ -0,0 +1,381 @@
+import { db, eq, inArray, max } from "@repo/database";
+import { formLogicRulesTable, formLogicConditionsTable } from "@repo/database/models/form-logic";
+import { formFieldsTable } from "@repo/database/models/form-field";
+import { formSegmentsTable } from "@repo/database/models/form-segment";
+import { requireEditor } from "../form";
+import {
+ createLogicRuleInput,
+ type CreateLogicRuleInputType,
+ updateLogicRuleInput,
+ type UpdateLogicRuleInputType,
+ deleteLogicRuleInput,
+ type DeleteLogicRuleInputType,
+ listLogicRulesInput,
+ type ListLogicRulesInputType,
+ logicConditionInput,
+ type LogicConditionInputType,
+ VALUELESS_OPERATORS,
+ assertConditionShape,
+ assertRuleShape,
+ type LogicAction,
+ type LogicMatch,
+} from "./model";
+
+/** A rule row with its conditions attached, ordered for display. */
+type RuleWithConditions = typeof formLogicRulesTable.$inferSelect & {
+ conditions: Array;
+};
+
+class FormLogicService {
+ /**
+ * Attach conditions to rule rows.
+ *
+ * One `IN (...)` query for the whole batch rather than one per rule: a form
+ * with twenty branches would otherwise turn a single list into twenty-one
+ * round-trips, and this runs on the public form read that every respondent
+ * pays for.
+ */
+ private async withConditions(
+ rules: Array,
+ ): Promise {
+ if (rules.length === 0) return [];
+
+ const conditions = await db
+ .select()
+ .from(formLogicConditionsTable)
+ .where(
+ inArray(
+ formLogicConditionsTable.ruleId,
+ rules.map((r) => r.id),
+ ),
+ )
+ .orderBy(formLogicConditionsTable.index);
+
+ const byRuleId = new Map>();
+ for (const condition of conditions) {
+ const list = byRuleId.get(condition.ruleId);
+ if (list) list.push(condition);
+ else byRuleId.set(condition.ruleId, [condition]);
+ }
+
+ return rules.map((rule) => ({ ...rule, conditions: byRuleId.get(rule.id) ?? [] }));
+ }
+
+ /**
+ * Every question and segment a rule points at — as a condition source or as
+ * a destination — has to live in the same form as the rule.
+ *
+ * The FK columns guarantee the rows exist; only this guarantees they're in
+ * scope. Without it an editor with access to form A could read an answer
+ * from form B, leaking B's structure into A's routing.
+ *
+ * Both id sets are checked in one query each, so adding a fifth condition
+ * doesn't add a fifth round-trip.
+ */
+ private async assertReferencesBelongToForm(
+ formId: string,
+ fieldIds: Array,
+ segmentIds: Array,
+ ): Promise {
+ const fields = [...new Set(fieldIds.filter((id): id is string => !!id))];
+ const segments = [...new Set(segmentIds.filter((id): id is string => !!id))];
+
+ if (fields.length > 0) {
+ const rows = await db
+ .select({ id: formFieldsTable.id, formId: formFieldsTable.formId })
+ .from(formFieldsTable)
+ .where(inArray(formFieldsTable.id, fields));
+
+ if (rows.length !== fields.length) throw new Error("A referenced question no longer exists");
+ const stray = rows.find((r) => r.formId !== formId);
+ if (stray) throw new Error("A referenced question belongs to a different form");
+ }
+
+ if (segments.length > 0) {
+ const rows = await db
+ .select({ id: formSegmentsTable.id, formId: formSegmentsTable.formId })
+ .from(formSegmentsTable)
+ .where(inArray(formSegmentsTable.id, segments));
+
+ if (rows.length !== segments.length) throw new Error("A referenced segment no longer exists");
+ const stray = rows.find((r) => r.formId !== formId);
+ if (stray) throw new Error("A referenced segment belongs to a different form");
+ }
+ }
+
+ /** Normalise a condition for storage: SQL NULL (not JSON null) when the
+ * operator takes no operand, since the CHECK tests `value IS NULL`. */
+ private conditionValues(ruleId: string, conditions: LogicConditionInputType[]) {
+ return conditions.map((condition, i) => ({
+ ruleId,
+ fieldId: condition.fieldId,
+ operator: condition.operator,
+ value: VALUELESS_OPERATORS.includes(condition.operator) ? null : (condition.value ?? null),
+ index: condition.index ?? String(i + 1),
+ }));
+ }
+
+ public async createLogicRule(payload: CreateLogicRuleInputType & { userId: string }) {
+ const {
+ formId,
+ fieldId,
+ match,
+ conditions,
+ action,
+ targetFieldId,
+ targetSegmentId,
+ elseAction,
+ elseTargetFieldId,
+ elseTargetSegmentId,
+ index: clientIndex,
+ } = await createLogicRuleInput.parseAsync(payload);
+
+ await requireEditor(formId, payload.userId);
+
+ // Pairing rules live here rather than in the Zod schema — a refined input
+ // schema breaks OpenAPI generation for the whole API. See the note on
+ // assertConditionShape.
+ assertRuleShape({
+ fieldId,
+ action,
+ targetFieldId,
+ targetSegmentId,
+ elseAction,
+ elseTargetFieldId,
+ elseTargetSegmentId,
+ });
+ conditions.forEach(assertConditionShape);
+
+ await this.assertReferencesBelongToForm(
+ formId,
+ [fieldId, targetFieldId, elseTargetFieldId, ...conditions.map((c) => c.fieldId)],
+ [targetSegmentId, elseTargetSegmentId],
+ );
+
+ let index = clientIndex;
+ if (index === undefined) {
+ const rows = await db
+ .select({ maxIndex: max(formLogicRulesTable.index) })
+ .from(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.fieldId, fieldId));
+ const current = rows[0]?.maxIndex;
+ index = String(current ? parseFloat(current) + 1 : 1);
+ }
+
+ // Rule and conditions are one unit. A rule that committed without its
+ // conditions would be a branch that never matches, silently changing the
+ // form's routing — worse than the create failing outright.
+ return db.transaction(async (tx) => {
+ const insertResult = await tx
+ .insert(formLogicRulesTable)
+ .values({
+ formId,
+ fieldId,
+ match,
+ action,
+ targetFieldId: targetFieldId || null,
+ targetSegmentId: targetSegmentId || null,
+ elseAction: elseAction || null,
+ elseTargetFieldId: elseTargetFieldId || null,
+ elseTargetSegmentId: elseTargetSegmentId || null,
+ index: index as string,
+ })
+ .returning({ id: formLogicRulesTable.id, index: formLogicRulesTable.index });
+
+ const created = insertResult[0];
+ if (!created?.id) throw new Error("Failed to create branching rule");
+
+ if (conditions.length > 0) {
+ await tx
+ .insert(formLogicConditionsTable)
+ .values(this.conditionValues(created.id, conditions));
+ }
+
+ return { id: created.id, index: created.index };
+ });
+ }
+
+ public async updateLogicRule(payload: UpdateLogicRuleInputType & { userId: string }) {
+ const {
+ id,
+ match,
+ conditions,
+ action,
+ targetFieldId,
+ targetSegmentId,
+ elseAction,
+ elseTargetFieldId,
+ elseTargetSegmentId,
+ index,
+ expectedVersion,
+ } = await updateLogicRuleInput.parseAsync(payload);
+
+ const existingResult = await db
+ .select()
+ .from(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.id, id));
+ const existing = existingResult[0];
+ if (!existing) throw new Error("Branching rule not found");
+
+ await requireEditor(existing.formId, payload.userId);
+
+ if (expectedVersion !== undefined && existing.version !== expectedVersion) {
+ throw new Error(
+ `Branching rule was modified by someone else (expected version ${expectedVersion}, got ${existing.version}). Reload and try again.`,
+ );
+ }
+
+ /* Merge the patch onto the stored row before validating. A partial update
+ * only carries what the author touched, so the then/else pairings are only
+ * meaningful once applied on top of what's already there. */
+ const nextMatch: LogicMatch = (match ?? existing.match) as LogicMatch;
+ const nextAction: LogicAction = (action ?? existing.action) as LogicAction;
+ const nextElseAction: LogicAction | null =
+ elseAction !== undefined
+ ? (elseAction as LogicAction | null)
+ : (existing.elseAction as LogicAction | null);
+
+ let nextTargetFieldId = targetFieldId !== undefined ? targetFieldId : existing.targetFieldId;
+ let nextTargetSegmentId =
+ targetSegmentId !== undefined ? targetSegmentId : existing.targetSegmentId;
+ let nextElseTargetFieldId =
+ elseTargetFieldId !== undefined ? elseTargetFieldId : existing.elseTargetFieldId;
+ let nextElseTargetSegmentId =
+ elseTargetSegmentId !== undefined ? elseTargetSegmentId : existing.elseTargetSegmentId;
+
+ // Switching a side's action invalidates the target the other kind of
+ // action used. Clearing it here rather than erroring keeps the editor's
+ // flow simple: pick a different action, pick its target, save.
+ if (nextAction === "JUMP_TO_FIELD") nextTargetSegmentId = null;
+ if (nextAction === "JUMP_TO_SEGMENT") nextTargetFieldId = null;
+ if (nextAction === "SUBMIT" || nextAction === "CONTINUE") {
+ nextTargetFieldId = null;
+ nextTargetSegmentId = null;
+ }
+ if (nextElseAction === "JUMP_TO_FIELD") nextElseTargetSegmentId = null;
+ if (nextElseAction === "JUMP_TO_SEGMENT") nextElseTargetFieldId = null;
+ if (!nextElseAction || nextElseAction === "SUBMIT" || nextElseAction === "CONTINUE") {
+ nextElseTargetFieldId = null;
+ nextElseTargetSegmentId = null;
+ }
+
+ assertRuleShape({
+ fieldId: existing.fieldId,
+ action: nextAction,
+ targetFieldId: nextTargetFieldId,
+ targetSegmentId: nextTargetSegmentId,
+ elseAction: nextElseAction,
+ elseTargetFieldId: nextElseTargetFieldId,
+ elseTargetSegmentId: nextElseTargetSegmentId,
+ });
+
+ let parsedConditions: LogicConditionInputType[] | undefined;
+ if (conditions !== undefined) {
+ parsedConditions = await Promise.all(
+ conditions.map((condition) => logicConditionInput.parseAsync(condition)),
+ );
+ parsedConditions.forEach(assertConditionShape);
+ }
+
+ await this.assertReferencesBelongToForm(
+ existing.formId,
+ [
+ nextTargetFieldId,
+ nextElseTargetFieldId,
+ ...(parsedConditions?.map((c) => c.fieldId) ?? []),
+ ],
+ [nextTargetSegmentId, nextElseTargetSegmentId],
+ );
+
+ return db.transaction(async (tx) => {
+ const updateResult = await tx
+ .update(formLogicRulesTable)
+ .set({
+ match: nextMatch,
+ action: nextAction,
+ targetFieldId: nextTargetFieldId,
+ targetSegmentId: nextTargetSegmentId,
+ elseAction: nextElseAction,
+ elseTargetFieldId: nextElseTargetFieldId,
+ elseTargetSegmentId: nextElseTargetSegmentId,
+ ...(index !== undefined ? { index } : {}),
+ version: existing.version + 1,
+ })
+ .where(eq(formLogicRulesTable.id, id))
+ .returning({ id: formLogicRulesTable.id, version: formLogicRulesTable.version });
+
+ const updated = updateResult[0];
+ if (!updated?.id) throw new Error("Update raced with another change — please retry");
+
+ /* Conditions are replaced wholesale rather than diffed. The set is small
+ * and always edited as a group, and delete-then-insert inside the
+ * transaction is both simpler and immune to the ordering bugs a
+ * three-way diff invites. Omitting `conditions` leaves them alone. */
+ if (parsedConditions !== undefined) {
+ await tx.delete(formLogicConditionsTable).where(eq(formLogicConditionsTable.ruleId, id));
+
+ if (parsedConditions.length > 0) {
+ await tx
+ .insert(formLogicConditionsTable)
+ .values(this.conditionValues(id, parsedConditions));
+ }
+ }
+
+ return { id: updated.id, version: updated.version };
+ });
+ }
+
+ public async deleteLogicRule(payload: DeleteLogicRuleInputType & { userId: string }) {
+ const { id } = await deleteLogicRuleInput.parseAsync(payload);
+
+ const existingResult = await db
+ .select()
+ .from(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.id, id));
+ const existing = existingResult[0];
+ if (!existing) throw new Error("Branching rule not found");
+
+ await requireEditor(existing.formId, payload.userId);
+
+ // Conditions go with it via ON DELETE CASCADE on `rule_id`.
+ const deleteResult = await db
+ .delete(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.id, id))
+ .returning({ id: formLogicRulesTable.id });
+
+ if (!deleteResult[0]?.id) {
+ throw new Error("Failed to delete branching rule or rule not found");
+ }
+
+ return { success: true };
+ }
+
+ public async listLogicRules(payload: ListLogicRulesInputType & { userId: string }) {
+ const { formId } = await listLogicRulesInput.parseAsync(payload);
+
+ await requireEditor(formId, payload.userId);
+
+ const rules = await db
+ .select()
+ .from(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.formId, formId))
+ .orderBy(formLogicRulesTable.index);
+
+ return this.withConditions(rules);
+ }
+
+ /** Unauthenticated read for the public form renderer, which needs the rules
+ * to know where each answer leads. Access control is the caller's job —
+ * `getFormById` is already public. */
+ public async listRulesForPublicForm(formId: string) {
+ const rules = await db
+ .select()
+ .from(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.formId, formId))
+ .orderBy(formLogicRulesTable.index);
+
+ return this.withConditions(rules);
+ }
+}
+
+export default FormLogicService;
diff --git a/packages/services/form-logic/model.ts b/packages/services/form-logic/model.ts
new file mode 100644
index 0000000..9cfad9f
--- /dev/null
+++ b/packages/services/form-logic/model.ts
@@ -0,0 +1,304 @@
+import { z } from "zod";
+
+/** Mirrors `logicOperatorEnum` in packages/database/models/form-logic.ts. */
+export const logicOperatorZodEnum = z.enum([
+ "EQUALS",
+ "NOT_EQUALS",
+ "CONTAINS",
+ "NOT_CONTAINS",
+ "GREATER_THAN",
+ "LESS_THAN",
+ "IS_EMPTY",
+ "IS_NOT_EMPTY",
+ "IS_ANY_OF",
+ "IS_NONE_OF",
+ "STARTS_WITH",
+ "ENDS_WITH",
+]);
+export type LogicOperator = z.infer;
+
+/** Mirrors `logicMatchEnum`. ALL = every condition, ANY = at least one. */
+export const logicMatchZodEnum = z.enum(["ALL", "ANY"]);
+export type LogicMatch = z.infer;
+
+/** Mirrors `logicActionEnum`. */
+export const logicActionZodEnum = z.enum([
+ "JUMP_TO_FIELD",
+ "JUMP_TO_SEGMENT",
+ "SUBMIT",
+ "CONTINUE",
+]);
+export type LogicAction = z.infer;
+
+/** Operators that test presence and therefore take no operand. */
+export const VALUELESS_OPERATORS: readonly LogicOperator[] = ["IS_EMPTY", "IS_NOT_EMPTY"];
+
+/** Operators whose operand is a list of choices rather than a single value. */
+export const MULTI_VALUE_OPERATORS: readonly LogicOperator[] = ["IS_ANY_OF", "IS_NONE_OF"];
+
+/** Actions that carry no destination. */
+export const TARGETLESS_ACTIONS: readonly LogicAction[] = ["SUBMIT", "CONTINUE"];
+
+/* ─── Conditions ──────────────────────────────────────────────────────── */
+
+export const logicConditionInput = z.object({
+ fieldId: z
+ .string()
+ .uuid()
+ .describe("Question whose answer this condition reads — any question in the form"),
+ operator: logicOperatorZodEnum.describe("How the answer is compared"),
+ value: z
+ .any()
+ .optional()
+ .nullable()
+ .describe("Operand; omitted for IS_EMPTY / IS_NOT_EMPTY, an array for IS_ANY_OF / IS_NONE_OF"),
+ index: z
+ .union([z.number(), z.string()])
+ .transform((val) => String(val))
+ .optional()
+ .describe("Display order within the rule"),
+});
+export type LogicConditionInputType = z.infer;
+
+export const logicConditionOutput = z.object({
+ id: z.string().uuid(),
+ ruleId: z.string().uuid(),
+ fieldId: z.string().uuid(),
+ operator: logicOperatorZodEnum,
+ value: z.any().nullable().optional(),
+ index: z.string(),
+ createdAt: z.any(),
+ updatedAt: z.any(),
+});
+export type LogicConditionOutputType = z.infer;
+
+/* ─── Validation ──────────────────────────────────────────────────────── */
+
+/**
+ * Check one condition's operator/value pairing.
+ *
+ * A plain function, not a Zod `.superRefine()`. It has to be: the tRPC layer
+ * feeds every procedure's input schema to `trpc-to-openapi`, which calls
+ * `.omit()` on it to build the request body, and zod 4 rejects `.omit()` on
+ * any schema carrying a refinement — so one refined input schema takes down
+ * the whole API at startup, not just its own route.
+ *
+ * The CHECK constraints in Postgres are what actually guarantee no bad row can
+ * exist. This exists so the author gets "pick at least one option" rather than
+ * `violates check constraint "form_logic_conditions_value_matches_operator"`.
+ */
+export function assertConditionShape(condition: {
+ operator: LogicOperator;
+ value?: unknown;
+}): void {
+ const { operator, value } = condition;
+
+ if (VALUELESS_OPERATORS.includes(operator)) {
+ if (value !== undefined && value !== null && value !== "") {
+ throw new Error(
+ `${operator} checks whether an answer exists, so it must not carry a comparison value`,
+ );
+ }
+ return;
+ }
+
+ if (MULTI_VALUE_OPERATORS.includes(operator)) {
+ if (!Array.isArray(value) || value.length === 0) {
+ throw new Error(`${operator} needs at least one option to compare against`);
+ }
+ return;
+ }
+
+ if (value === undefined || value === null || value === "") {
+ throw new Error(`${operator} needs a value to compare the answer against`);
+ }
+}
+
+/**
+ * Check a branch's then/else sides.
+ *
+ * `elseAction` is optional throughout: a rule without one is a guard clause
+ * ("if they said no, skip ahead") that stays out of the way when its
+ * conditions don't hold, and the next rule gets a turn. A rule with one is a
+ * fork that always decides.
+ */
+export function assertRuleShape(rule: {
+ fieldId?: string;
+ action: LogicAction;
+ targetFieldId?: string | null;
+ targetSegmentId?: string | null;
+ elseAction?: LogicAction | null;
+ elseTargetFieldId?: string | null;
+ elseTargetSegmentId?: string | null;
+}): void {
+ const {
+ fieldId,
+ action,
+ targetFieldId,
+ targetSegmentId,
+ elseAction,
+ elseTargetFieldId,
+ elseTargetSegmentId,
+ } = rule;
+
+ const checkSide = (
+ side: "then" | "otherwise",
+ sideAction: LogicAction,
+ fieldTarget?: string | null,
+ segmentTarget?: string | null,
+ ) => {
+ const label = side === "then" ? "the matching branch" : "the otherwise branch";
+
+ if (sideAction === "JUMP_TO_FIELD") {
+ if (!fieldTarget) throw new Error(`Choose the question ${label} jumps to`);
+ if (segmentTarget) throw new Error(`${label} cannot target both a question and a segment`);
+ return;
+ }
+ if (sideAction === "JUMP_TO_SEGMENT") {
+ if (!segmentTarget) throw new Error(`Choose the segment ${label} jumps to`);
+ if (fieldTarget) throw new Error(`${label} cannot target both a question and a segment`);
+ return;
+ }
+ if (fieldTarget || segmentTarget) {
+ throw new Error(`${label} ends the flow, so it cannot also have a jump target`);
+ }
+ };
+
+ checkSide("then", action, targetFieldId, targetSegmentId);
+
+ if (elseAction) {
+ checkSide("otherwise", elseAction, elseTargetFieldId, elseTargetSegmentId);
+ } else if (elseTargetFieldId || elseTargetSegmentId) {
+ throw new Error("Set what the otherwise branch does before choosing where it goes");
+ }
+
+ if (fieldId && targetFieldId === fieldId) {
+ throw new Error("A question cannot branch back to itself");
+ }
+ if (fieldId && elseTargetFieldId === fieldId) {
+ throw new Error("A question cannot branch back to itself");
+ }
+}
+
+/* ─── Rules ───────────────────────────────────────────────────────────── */
+
+/* Both input schemas are plain objects with no refinements — see the note on
+ * `assertConditionShape` for why. The service validates after parsing. */
+
+export const createLogicRuleInput = z
+ .object({
+ formId: z.string().uuid().describe("ID of the parent form"),
+ fieldId: z.string().uuid().describe("Question after which this branch is evaluated"),
+ match: logicMatchZodEnum
+ .default("ALL")
+ .describe("ALL = every condition must hold, ANY = at least one"),
+ conditions: z
+ .array(logicConditionInput)
+ .default([])
+ .describe("Tests combined per `match`; a branch with none never matches"),
+
+ action: logicActionZodEnum.describe("Where a match sends the respondent"),
+ targetFieldId: z.string().uuid().optional().nullable().describe("Destination question"),
+ targetSegmentId: z.string().uuid().optional().nullable().describe("Destination segment"),
+
+ elseAction: logicActionZodEnum
+ .optional()
+ .nullable()
+ .describe("Where a non-match goes; omit to fall through to the next rule"),
+ elseTargetFieldId: z.string().uuid().optional().nullable(),
+ elseTargetSegmentId: z.string().uuid().optional().nullable(),
+
+ index: z
+ .union([z.number(), z.string()])
+ .transform((val) => String(val))
+ .optional()
+ .describe("Evaluation order for the trigger question's rules; lower wins"),
+ })
+ .describe("Create a conditional branch");
+export type CreateLogicRuleInputType = z.infer;
+
+export const updateLogicRuleInput = z
+ .object({
+ id: z.string().uuid().describe("ID of the rule to update"),
+ match: logicMatchZodEnum.optional(),
+ // Supplying this replaces the rule's whole condition set. Omitting it
+ // leaves the conditions untouched, so a rename-only edit doesn't have to
+ // round-trip them.
+ conditions: z
+ .array(logicConditionInput)
+ .optional()
+ .describe("Replaces every condition on the rule when supplied"),
+
+ action: logicActionZodEnum.optional(),
+ targetFieldId: z.string().uuid().optional().nullable(),
+ targetSegmentId: z.string().uuid().optional().nullable(),
+
+ elseAction: logicActionZodEnum.optional().nullable(),
+ elseTargetFieldId: z.string().uuid().optional().nullable(),
+ elseTargetSegmentId: z.string().uuid().optional().nullable(),
+
+ index: z
+ .union([z.number(), z.string()])
+ .transform((val) => String(val))
+ .optional(),
+ expectedVersion: z
+ .number()
+ .int()
+ .nonnegative()
+ .optional()
+ .describe("Optimistic-lock token returned by the previous read of this rule"),
+ })
+ .describe("Update a conditional branch");
+export type UpdateLogicRuleInputType = z.infer;
+
+export const deleteLogicRuleInput = z.object({
+ id: z.string().uuid().describe("ID of the rule to delete"),
+});
+export type DeleteLogicRuleInputType = z.infer;
+
+export const listLogicRulesInput = z.object({
+ formId: z.string().uuid().describe("ID of the parent form"),
+});
+export type ListLogicRulesInputType = z.infer;
+
+export const getLogicRuleOutput = z.object({
+ id: z.string().uuid(),
+ formId: z.string().uuid(),
+ fieldId: z.string().uuid().describe("Question after which this branch is evaluated"),
+ match: logicMatchZodEnum,
+ conditions: z.array(logicConditionOutput),
+
+ action: logicActionZodEnum,
+ targetFieldId: z.string().uuid().nullable().optional(),
+ targetSegmentId: z.string().uuid().nullable().optional(),
+
+ elseAction: logicActionZodEnum.nullable().optional(),
+ elseTargetFieldId: z.string().uuid().nullable().optional(),
+ elseTargetSegmentId: z.string().uuid().nullable().optional(),
+
+ index: z.string(),
+ version: z.number().int(),
+ createdAt: z.any(),
+ updatedAt: z.any(),
+});
+export type GetLogicRuleOutputType = z.infer;
+
+export const listLogicRulesOutput = z.array(getLogicRuleOutput);
+export type ListLogicRulesOutputType = z.infer;
+
+export const createLogicRuleOutput = z.object({
+ id: z.string().uuid().describe("ID of the created rule"),
+ index: z.string().describe("Fractional index the rule was created at"),
+});
+export type CreateLogicRuleOutputType = z.infer;
+
+export const updateLogicRuleOutput = z.object({
+ id: z.string().uuid(),
+ version: z.number().int().describe("New optimistic-lock version after the update"),
+});
+export type UpdateLogicRuleOutputType = z.infer;
+
+export const deleteLogicRuleOutput = z.object({
+ success: z.boolean(),
+});
+export type DeleteLogicRuleOutputType = z.infer;
diff --git a/packages/services/form-segment/index.ts b/packages/services/form-segment/index.ts
new file mode 100644
index 0000000..de564a9
--- /dev/null
+++ b/packages/services/form-segment/index.ts
@@ -0,0 +1,262 @@
+import { db, eq, and, isNull, max, count } from "@repo/database";
+import { formSegmentsTable } from "@repo/database/models/form-segment";
+import { formFieldsTable } from "@repo/database/models/form-field";
+import { requireEditor } from "../form";
+import {
+ createFormSegmentInput,
+ type CreateFormSegmentInputType,
+ updateFormSegmentInput,
+ type UpdateFormSegmentInputType,
+ deleteFormSegmentInput,
+ type DeleteFormSegmentInputType,
+ listFormSegmentsInput,
+ type ListFormSegmentsInputType,
+} from "./model";
+
+/** Title given to the segment that adopts questions written before the form
+ * was ever segmented. */
+const DEFAULT_SEGMENT_TITLE = "Segment 1";
+
+class FormSegmentService {
+ /**
+ * Add a segment to a form.
+ *
+ * The interesting case is the first one. Before any segment exists, a
+ * form's questions all carry `segment_id = NULL`, which the renderer
+ * treats as one implicit opening segment. If we simply inserted the new
+ * segment, those questions would still be unassigned and the editor would
+ * see an empty "Segment 2" next to a nameless pile of existing questions.
+ *
+ * So the first `createFormSegment` on a form does two things: materialise
+ * "Segment 1" and adopt every loose question into it, then create the
+ * segment that was actually asked for. That yields exactly the mental
+ * model the editor has — segment 1 holds what I already wrote, segment 2
+ * is the new empty one.
+ *
+ * Wrapped in a transaction because the intermediate state (a default
+ * segment that exists but has adopted nothing) would strand questions in
+ * an implicit segment that is no longer implicit. Partial application here
+ * is worse than failure.
+ */
+ public async createFormSegment(payload: CreateFormSegmentInputType & { userId: string }) {
+ const {
+ formId,
+ title,
+ description,
+ index: clientIndex,
+ adoptUnassignedFields,
+ } = await createFormSegmentInput.parseAsync(payload);
+
+ await requireEditor(formId, payload.userId);
+
+ return db.transaction(async (tx) => {
+ const existing = await tx
+ .select({ value: count() })
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.formId, formId));
+ const segmentCount = Number(existing[0]?.value ?? 0);
+
+ let createdDefaultSegment: typeof formSegmentsTable.$inferSelect | null = null;
+
+ if (segmentCount === 0 && adoptUnassignedFields) {
+ const defaultRows = await tx
+ .insert(formSegmentsTable)
+ .values({
+ formId,
+ title: DEFAULT_SEGMENT_TITLE,
+ index: "1",
+ })
+ .returning();
+
+ createdDefaultSegment = defaultRows[0] ?? null;
+ if (!createdDefaultSegment) {
+ throw new Error("Failed to create the default segment");
+ }
+
+ await tx
+ .update(formFieldsTable)
+ .set({ segmentId: createdDefaultSegment.id })
+ .where(and(eq(formFieldsTable.formId, formId), isNull(formFieldsTable.segmentId)));
+ }
+
+ // Same rationale as `createFormField`: honour a client-supplied index
+ // so a batch of new segments doesn't have every parallel call read the
+ // same MAX(index) and collide on UNIQUE(form_id, index).
+ let index = clientIndex;
+ if (index === undefined) {
+ const rows = await tx
+ .select({ maxIndex: max(formSegmentsTable.index) })
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.formId, formId));
+ const current = rows[0]?.maxIndex;
+ index = String(current ? parseFloat(current) + 1 : 1);
+ }
+
+ const insertResult = await tx
+ .insert(formSegmentsTable)
+ .values({
+ formId,
+ title,
+ description: description || undefined,
+ index,
+ })
+ .returning({ id: formSegmentsTable.id, index: formSegmentsTable.index });
+
+ const created = insertResult[0];
+ if (!created?.id) {
+ throw new Error("Failed to create form segment");
+ }
+
+ return {
+ id: created.id,
+ index: created.index,
+ createdDefaultSegment,
+ };
+ });
+ }
+
+ public async updateFormSegment(payload: UpdateFormSegmentInputType & { userId: string }) {
+ const { id, title, description, index, expectedVersion } =
+ await updateFormSegmentInput.parseAsync(payload);
+
+ const existingResult = await db
+ .select()
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.id, id));
+ const existing = existingResult[0];
+ if (!existing) {
+ throw new Error("Form segment not found");
+ }
+
+ await requireEditor(existing.formId, payload.userId);
+
+ // Optimistic-lock check — mirrors `updateFormField`. Surfaces a
+ // recognisable conflict so the builder can reload rather than
+ // silently overwrite a collaborator's edit.
+ if (expectedVersion !== undefined && existing.version !== expectedVersion) {
+ throw new Error(
+ `Form segment was modified by someone else (expected version ${expectedVersion}, got ${existing.version}). Reload and try again.`,
+ );
+ }
+
+ const updateResult = await db
+ .update(formSegmentsTable)
+ .set({
+ ...(title !== undefined ? { title } : {}),
+ ...(description !== undefined ? { description } : {}),
+ ...(index !== undefined ? { index } : {}),
+ version: existing.version + 1,
+ })
+ .where(
+ expectedVersion !== undefined
+ ? and(eq(formSegmentsTable.id, id), eq(formSegmentsTable.version, expectedVersion))
+ : eq(formSegmentsTable.id, id),
+ )
+ .returning({ id: formSegmentsTable.id, version: formSegmentsTable.version });
+
+ const updated = updateResult[0];
+ if (!updated?.id) {
+ throw new Error("Update raced with another change — please retry");
+ }
+
+ return { id: updated.id, version: updated.version };
+ }
+
+ /**
+ * Delete a segment.
+ *
+ * Its questions are NOT deleted — they're moved to a neighbouring segment
+ * first. Deleting a page in a form builder should never be a destructive
+ * act on the work inside it; that surprise is unrecoverable, and an editor
+ * who wants the questions gone can delete them deliberately.
+ *
+ * The questions are re-parented explicitly rather than left to the FK's ON
+ * DELETE SET NULL. A NULL `segment_id` means "the implicit first segment",
+ * which sorts ahead of every real one — so relying on the FK would teleport
+ * the questions of a deleted middle segment to the very front of the form.
+ * Adopting the nearest PRECEDING segment keeps them where the editor last
+ * saw them. Deleting the first segment falls forward to the next one
+ * instead, and deleting the only segment is the one case where NULL is
+ * genuinely correct: the form goes back to being unsegmented.
+ *
+ * Branch rules that targeted this segment go away with it (ON DELETE
+ * CASCADE on `target_segment_id`), because a rule pointing at a segment
+ * that no longer exists would dead-end the respondent.
+ */
+ public async deleteFormSegment(payload: DeleteFormSegmentInputType & { userId: string }) {
+ const { id } = await deleteFormSegmentInput.parseAsync(payload);
+
+ const existingResult = await db
+ .select()
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.id, id));
+ const existing = existingResult[0];
+ if (!existing) {
+ throw new Error("Form segment not found");
+ }
+
+ await requireEditor(existing.formId, payload.userId);
+
+ return db.transaction(async (tx) => {
+ const siblings = await tx
+ .select({ id: formSegmentsTable.id, index: formSegmentsTable.index })
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.formId, existing.formId))
+ .orderBy(formSegmentsTable.index);
+
+ const position = siblings.findIndex((s) => s.id === id);
+ const previous = position > 0 ? siblings[position - 1] : undefined;
+ const next = position >= 0 ? siblings[position + 1] : undefined;
+ const adoptiveSegmentId = previous?.id ?? next?.id ?? null;
+
+ // Counted before the move: afterwards nothing matches this segment id.
+ const affected = await tx
+ .select({ value: count() })
+ .from(formFieldsTable)
+ .where(eq(formFieldsTable.segmentId, id));
+ const releasedFieldCount = Number(affected[0]?.value ?? 0);
+
+ if (releasedFieldCount > 0) {
+ await tx
+ .update(formFieldsTable)
+ .set({ segmentId: adoptiveSegmentId })
+ .where(eq(formFieldsTable.segmentId, id));
+ }
+
+ const deleteResult = await tx
+ .delete(formSegmentsTable)
+ .where(eq(formSegmentsTable.id, id))
+ .returning({ id: formSegmentsTable.id });
+
+ if (!deleteResult[0]?.id) {
+ throw new Error("Failed to delete form segment or segment not found");
+ }
+
+ return { success: true, releasedFieldCount };
+ });
+ }
+
+ public async listFormSegments(payload: ListFormSegmentsInputType & { userId: string }) {
+ const { formId } = await listFormSegmentsInput.parseAsync(payload);
+
+ await requireEditor(formId, payload.userId);
+
+ return db
+ .select()
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.formId, formId))
+ .orderBy(formSegmentsTable.index);
+ }
+
+ /** Unauthenticated read used by the public form renderer. Access control
+ * is the caller's job — `getFormById` is already public. */
+ public async listSegmentsForPublicForm(formId: string) {
+ return db
+ .select()
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.formId, formId))
+ .orderBy(formSegmentsTable.index);
+ }
+}
+
+export default FormSegmentService;
diff --git a/packages/services/form-segment/model.ts b/packages/services/form-segment/model.ts
new file mode 100644
index 0000000..dd2c38d
--- /dev/null
+++ b/packages/services/form-segment/model.ts
@@ -0,0 +1,104 @@
+import { z } from "zod";
+
+// Same sanitisation policy as the rest of the services: `.trim()` on every
+// string so whitespace-only differences can't create near-duplicate titles,
+// and length caps that match the varchar/text columns.
+
+export const createFormSegmentInput = z.object({
+ formId: z.string().uuid().describe("ID of the parent form"),
+ title: z.string().trim().min(1).max(255).describe("Title shown above the segment's questions"),
+ description: z
+ .string()
+ .trim()
+ .max(2000)
+ .optional()
+ .nullable()
+ .describe("Help text shown under the segment title"),
+ index: z
+ .union([z.number(), z.string()])
+ .transform((val) => String(val))
+ .optional()
+ .describe("Fractional index for sorting"),
+ // On a form that has no segments yet, the default behaviour is to also
+ // create a "Segment 1" and move every unassigned question into it — so a
+ // single call turns a flat form into a segmented one, which is what a
+ // direct API caller wants.
+ //
+ // The builder passes `false`. It keeps segments and question assignments in
+ // one local draft and saves them together, so it creates "Segment 1"
+ // itself; letting the server also create one would produce a duplicate.
+ adoptUnassignedFields: z
+ .boolean()
+ .default(true)
+ .describe("Also create a first segment and move unassigned questions into it"),
+});
+export type CreateFormSegmentInputType = z.infer;
+
+export const updateFormSegmentInput = z.object({
+ id: z.string().uuid().describe("ID of the segment to update"),
+ title: z.string().trim().min(1).max(255).optional().describe("Title of the segment"),
+ description: z.string().trim().max(2000).optional().nullable().describe("Description"),
+ index: z
+ .union([z.number(), z.string()])
+ .transform((val) => String(val))
+ .optional()
+ .describe("Fractional index for sorting"),
+ expectedVersion: z
+ .number()
+ .int()
+ .nonnegative()
+ .optional()
+ .describe("Optimistic-lock token returned by the previous read of this segment"),
+});
+export type UpdateFormSegmentInputType = z.infer;
+
+export const deleteFormSegmentInput = z.object({
+ id: z.string().uuid().describe("ID of the segment to delete"),
+});
+export type DeleteFormSegmentInputType = z.infer;
+
+export const listFormSegmentsInput = z.object({
+ formId: z.string().uuid().describe("ID of the parent form"),
+});
+export type ListFormSegmentsInputType = z.infer;
+
+export const getFormSegmentOutput = z.object({
+ id: z.string().uuid().describe("ID of the segment"),
+ formId: z.string().uuid().describe("ID of the parent form"),
+ title: z.string().describe("Title of the segment"),
+ description: z.string().nullable().optional().describe("Description of the segment"),
+ index: z.string().describe("Fractional index for sorting"),
+ version: z.number().int().describe("Optimistic-lock version"),
+ createdAt: z.any().describe("Creation timestamp"),
+ updatedAt: z.any().describe("Last update timestamp"),
+});
+export type GetFormSegmentOutputType = z.infer;
+
+export const listFormSegmentsOutput = z.array(getFormSegmentOutput);
+export type ListFormSegmentsOutputType = z.infer;
+
+export const createFormSegmentOutput = z.object({
+ id: z.string().uuid().describe("ID of the created segment"),
+ index: z.string().describe("Fractional index the segment was created at"),
+ // When the form had no segments yet, creating one also materialises a
+ // "Segment 1" for the pre-existing questions. The caller needs to know
+ // that happened so it can show both segments without a refetch.
+ createdDefaultSegment: getFormSegmentOutput
+ .nullable()
+ .describe("The implicit first segment, if this call had to materialise it"),
+});
+export type CreateFormSegmentOutputType = z.infer;
+
+export const updateFormSegmentOutput = z.object({
+ id: z.string().uuid().describe("ID of the updated segment"),
+ version: z.number().int().describe("New optimistic-lock version after the update"),
+});
+export type UpdateFormSegmentOutputType = z.infer;
+
+export const deleteFormSegmentOutput = z.object({
+ success: z.boolean().describe("Whether deletion was successful"),
+ // Fields are not deleted with their segment — they fall back to the
+ // implicit first segment. Report how many moved so the UI can say so.
+ releasedFieldCount: z.number().int().describe("Questions that fell back to the first segment"),
+});
+export type DeleteFormSegmentOutputType = z.infer;
diff --git a/packages/services/form-submission/access.ts b/packages/services/form-submission/access.ts
new file mode 100644
index 0000000..5c46dfd
--- /dev/null
+++ b/packages/services/form-submission/access.ts
@@ -0,0 +1,110 @@
+/**
+ * Who is allowed to answer a form.
+ *
+ * Separated from the submit path so the same rules can answer two different
+ * questions with one implementation: "may this person submit?" (enforced) and
+ * "what should we tell them before they start?" (the public form's gate). A
+ * second copy of the domain matching in the UI is how a respondent ends up
+ * being told they're allowed and then rejected.
+ */
+
+/** Sentinels the public form matches on to pick a screen. Kept stable. */
+export const SIGN_IN_REQUIRED_ERROR = "SIGN_IN_REQUIRED";
+export const DOMAIN_NOT_ALLOWED_ERROR = "DOMAIN_NOT_ALLOWED";
+export const ALREADY_RESPONDED_ERROR = "ALREADY_RESPONDED";
+
+/** The access-controlling settings, as stored on the form. */
+export interface FormAccessRules {
+ requireSignIn: boolean;
+ collectRespondentEmail: boolean;
+ oneResponsePerRespondent: boolean;
+ allowedEmailDomains?: string[] | null;
+}
+
+/**
+ * Does the form need a signed-in respondent?
+ *
+ * The three dependent settings each imply it, and this is the single place that
+ * says so. An editor who ticks "collect email" without ticking "require sign
+ * in" gets the requirement anyway rather than a setting that silently does
+ * nothing — there is no way to read an account's email without an account.
+ */
+export function requiresSignIn(rules: FormAccessRules): boolean {
+ return (
+ rules.requireSignIn ||
+ rules.collectRespondentEmail ||
+ rules.oneResponsePerRespondent ||
+ (rules.allowedEmailDomains?.length ?? 0) > 0
+ );
+}
+
+/** `dittya@nst.rishihood.edu.in` -> `nst.rishihood.edu.in` */
+export function emailDomain(email: string): string {
+ const at = email.lastIndexOf("@");
+ return at === -1 ? "" : email.slice(at + 1).trim().toLowerCase();
+}
+
+/**
+ * Normalise an author-supplied domain. Accepts what people actually type —
+ * `@example.com`, `https://example.com`, `Example.COM ` — because rejecting
+ * those would just mean a restriction that silently matches nothing.
+ */
+export function normaliseDomain(input: string): string {
+ return input
+ .trim()
+ .toLowerCase()
+ .replace(/^https?:\/\//, "")
+ .replace(/^@/, "")
+ .replace(/\/.*$/, "")
+ .replace(/^\.+|\.+$/g, "");
+}
+
+/**
+ * Is this email inside one of the allowed domains?
+ *
+ * Suffix matching, deliberately: an entry of `rishihood.edu.in` admits both
+ * `kamlesh@rishihood.edu.in` and `dittya@nst.rishihood.edu.in`, because a
+ * university that hands out per-department subdomains is still one institution
+ * and the author should not have to enumerate them.
+ *
+ * The boundary check is what keeps that from being too loose. Matching on a
+ * bare `endsWith` would also admit `evil-rishihood.edu.in`, so a longer domain
+ * only matches when the extra part ends at a dot.
+ */
+export function isEmailDomainAllowed(email: string, allowed?: string[] | null): boolean {
+ if (!allowed || allowed.length === 0) return true;
+
+ const domain = emailDomain(email);
+ if (!domain) return false;
+
+ return allowed.some((entry) => {
+ const candidate = normaliseDomain(entry);
+ if (!candidate) return false;
+ return domain === candidate || domain.endsWith(`.${candidate}`);
+ });
+}
+
+/** The respondent, as far as the server is concerned. Email comes from the
+ * session — never from the request body, or the domain rule would be a
+ * suggestion. */
+export interface Respondent {
+ id: string;
+ email: string;
+}
+
+/**
+ * Check a respondent against a form's rules. Throws one of the sentinels above,
+ * or returns cleanly.
+ */
+export function assertRespondentAllowed(
+ rules: FormAccessRules,
+ respondent: Respondent | null,
+): void {
+ if (!requiresSignIn(rules)) return;
+
+ if (!respondent) throw new Error(SIGN_IN_REQUIRED_ERROR);
+
+ if (!isEmailDomainAllowed(respondent.email, rules.allowedEmailDomains)) {
+ throw new Error(DOMAIN_NOT_ALLOWED_ERROR);
+ }
+}
diff --git a/packages/services/form-submission/index.ts b/packages/services/form-submission/index.ts
index 7016a65..39f8f2b 100644
--- a/packages/services/form-submission/index.ts
+++ b/packages/services/form-submission/index.ts
@@ -1,5 +1,11 @@
-import { db, eq, and, desc, gte, lt, count, usersTable } from "@repo/database";
+import { db, eq, and, desc, lt } from "@repo/database";
import { formsTable } from "@repo/database/models/form";
+import {
+ assertRespondentAllowed,
+ ALREADY_RESPONDED_ERROR,
+ type Respondent,
+} from "./access";
+export * from "./access";
import { formSubmissionsTable } from "@repo/database/models/form-submission";
import {
submitFormInput,
@@ -13,15 +19,16 @@ import {
// string stable — the public form page matches against it.
export const ALREADY_SUBMITTED_ERROR = "ALREADY_SUBMITTED";
-// Same contract, for "this form can't take any more responses". Raised both
-// when the form hits its own maxSubmissions cap and when the owner's monthly
-// plan quota is exhausted: to the person filling the form in, those are the
-// same dead end, and the plan case must not report the owner's tier or quota
-// to a stranger on a public page.
-export const LIMIT_REACHED_ERROR = "LIMIT_REACHED";
-
class FormSubmissionService {
- public async submitForm(payload: SubmitFormInputType) {
+ /**
+ * Record a response.
+ *
+ * `respondent` is deliberately a separate argument rather than part of the
+ * validated input: it comes from the session, and keeping it out of the Zod
+ * schema makes it impossible for a request body to supply one.
+ */
+ public async submitForm(payload: SubmitFormInputType & { respondent?: Respondent | null }) {
+ const { respondent } = payload;
const {
formId,
values,
@@ -51,22 +58,49 @@ class FormSubmissionService {
throw new Error("Form has expired");
}
- if (form.maxSubmissions !== null && form.maxSubmissions !== undefined) {
- const submissionsCountRows = await db
- .select({ value: count() })
+ /* ── Who may respond ─────────────────────────────────────────────────
+ *
+ * `respondent` is resolved from the session by the tRPC layer, never from
+ * the request body. That's the whole reason the domain restriction is worth
+ * anything: an email the client could type is an email the client could
+ * choose. */
+ const rules = {
+ requireSignIn: form.requireSignIn,
+ collectRespondentEmail: form.collectRespondentEmail,
+ oneResponsePerRespondent: form.oneResponsePerRespondent,
+ allowedEmailDomains: form.allowedEmailDomains,
+ };
+
+ assertRespondentAllowed(rules, respondent ?? null);
+
+ // One response per account, checked here rather than by a unique index:
+ // the rule is a per-form setting and an index can't be conditional on a
+ // column in another table. See the note on `form_submissions_form_respondent_idx`.
+ if (form.oneResponsePerRespondent && respondent) {
+ const prior = await db
+ .select({ id: formSubmissionsTable.id })
.from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, formId));
- const totalSubmissions = Number(submissionsCountRows[0]?.value ?? 0);
- if (totalSubmissions >= form.maxSubmissions) {
- throw new Error(LIMIT_REACHED_ERROR);
- }
+ .where(
+ and(
+ eq(formSubmissionsTable.formId, formId),
+ eq(formSubmissionsTable.respondentUserId, respondent.id),
+ ),
+ )
+ .limit(1);
+
+ if (prior[0]) throw new Error(ALREADY_RESPONDED_ERROR);
}
- // One-submission-per-visitor enforcement. Visitors who can't write
- // localStorage (private mode, storage disabled) send a null
- // visitorId and skip this check — idempotency-key dedupe still
- // protects them from double-clicks within a single page session.
- if (visitorId) {
+ // Legacy per-browser check, now conditional.
+ //
+ // This used to run unconditionally and was backed by a unique index, which
+ // made every form one-response-per-browser whether the author wanted it or
+ // not. It survives only as a courtesy for forms that ask for a single
+ // response but don't require signing in — there it's the only signal
+ // available. Visitors who can't write localStorage (private mode, storage
+ // disabled) send a null visitorId and skip it — idempotency-key dedupe
+ // still protects them from double-clicks within a page session.
+ if (form.oneResponsePerRespondent && !form.requireSignIn && visitorId) {
const prior = await db
.select({ id: formSubmissionsTable.id })
.from(formSubmissionsTable)
@@ -103,43 +137,6 @@ class FormSubmissionService {
}
}
- const userResult = await db
- .select({ plan: usersTable.plan })
- .from(usersTable)
- .where(eq(usersTable.id, form.ownerId));
- const userPlan = userResult[0]?.plan || "Free";
-
- let submissionLimit = 1000;
- if (userPlan === "Pro") submissionLimit = 10000;
- else if (userPlan === "Pro+") submissionLimit = 50000;
- else if (userPlan === "Business") submissionLimit = 500000;
-
- const startOfMonth = new Date();
- startOfMonth.setDate(1);
- startOfMonth.setHours(0, 0, 0, 0);
-
- // Count this month's submissions across all of the owner's forms in a
- // single COUNT query (no row materialization).
- const monthCountRows = await db
- .select({ value: count() })
- .from(formSubmissionsTable)
- .innerJoin(formsTable, eq(formSubmissionsTable.formId, formsTable.id))
- .where(
- and(
- eq(formsTable.ownerId, form.ownerId),
- gte(formSubmissionsTable.createdAt, startOfMonth),
- ),
- );
-
- const monthlyCount = Number(monthCountRows[0]?.value ?? 0);
- if (monthlyCount >= submissionLimit) {
- // Deliberately the same opaque sentinel as the per-form cap. This used
- // to throw the tier name and the quota number, which surfaced in a
- // toast on a public form — telling any respondent which plan the owner
- // is on. The owner sees the real reason in their dashboard.
- throw new Error(LIMIT_REACHED_ERROR);
- }
-
let insertResult;
try {
insertResult = await db
@@ -149,6 +146,12 @@ class FormSubmissionService {
values,
idempotencyKey: idempotencyKey ?? null,
visitorId: visitorId ?? null,
+ // Always recorded when known, because it's what "one response per
+ // respondent" is checked against on the next attempt. The email is
+ // only kept when the author asked for it — identity and contact
+ // details are separate decisions.
+ respondentUserId: respondent?.id ?? null,
+ respondentEmail: form.collectRespondentEmail ? (respondent?.email ?? null) : null,
referrer: referrer ?? null,
utmSource: utmSource ?? null,
utmMedium: utmMedium ?? null,
diff --git a/packages/services/form/index.ts b/packages/services/form/index.ts
index f3fa124..edf9b12 100644
--- a/packages/services/form/index.ts
+++ b/packages/services/form/index.ts
@@ -1,8 +1,11 @@
import { db, eq, and, gte, count, sql, usersTable } from "@repo/database";
import { formsTable } from "@repo/database/models/form";
import { formFieldsTable } from "@repo/database/models/form-field";
+import { formSegmentsTable } from "@repo/database/models/form-segment";
+import { formLogicRulesTable, formLogicConditionsTable } from "@repo/database/models/form-logic";
import { formSubmissionsTable } from "@repo/database/models/form-submission";
import { formCollaboratorsTable } from "@repo/database/models/form-collaborator";
+import { normaliseDomain } from "../form-submission/access";
import {
createFormInput,
@@ -149,34 +152,6 @@ class FormService {
public async createForm(payload: CreateFormInputType) {
const { title, description, slug, ownerId } = await createFormInput.parseAsync(payload);
- const userResult = await db
- .select({ plan: usersTable.plan })
- .from(usersTable)
- .where(eq(usersTable.id, ownerId));
- const userPlan = userResult[0]?.plan || "Free";
-
- let formLimit = 10;
- if (userPlan === "Pro") formLimit = 50;
- else if (userPlan === "Pro+") formLimit = 200;
- else if (userPlan === "Business") formLimit = Infinity;
-
- if (formLimit !== Infinity) {
- const startOfMonth = new Date();
- startOfMonth.setDate(1);
- startOfMonth.setHours(0, 0, 0, 0);
-
- const existingForms = await db
- .select({ value: count() })
- .from(formsTable)
- .where(and(eq(formsTable.ownerId, ownerId), gte(formsTable.createdAt, startOfMonth)));
-
- if (Number(existingForms[0]?.value ?? 0) >= formLimit) {
- throw new Error(
- `You have reached the limit of ${formLimit} forms per month for the ${userPlan} tier.`,
- );
- }
- }
-
const existingForm = await this.getFormBySlug(slug);
if (existingForm) {
throw new Error(`Form with slug ${slug} already exists`);
@@ -275,10 +250,38 @@ class FormService {
throw new Error("Form not found");
}
- const submissionsCountRows = await db
- .select({ value: count() })
- .from(formSubmissionsTable)
- .where(eq(formSubmissionsTable.formId, id));
+ // Four independent reads, so they go out together rather than paying four
+ // sequential round-trips. They're kept as separate statements instead of
+ // joined onto the query above because segments, rules and conditions are
+ // unrelated dimensions — joining them all would multiply rows
+ // (fields × segments × rules × conditions) and need de-duplicating
+ // client-side.
+ const [submissionsCountRows, segments, rules, conditionRows] = await Promise.all([
+ db
+ .select({ value: count() })
+ .from(formSubmissionsTable)
+ .where(eq(formSubmissionsTable.formId, id)),
+ db
+ .select()
+ .from(formSegmentsTable)
+ .where(eq(formSegmentsTable.formId, id))
+ .orderBy(formSegmentsTable.index),
+ db
+ .select()
+ .from(formLogicRulesTable)
+ .where(eq(formLogicRulesTable.formId, id))
+ .orderBy(formLogicRulesTable.index),
+ // Conditions carry no form_id of their own — they hang off a rule — so
+ // they're scoped with a join rather than a second lookup keyed on the
+ // rule ids, which would have to wait for the rules query to come back.
+ db
+ .select({ condition: formLogicConditionsTable })
+ .from(formLogicConditionsTable)
+ .innerJoin(formLogicRulesTable, eq(formLogicRulesTable.id, formLogicConditionsTable.ruleId))
+ .where(eq(formLogicRulesTable.formId, id))
+ .orderBy(formLogicConditionsTable.index),
+ ]);
+
const submissionsCount = Number(submissionsCountRows[0]?.value ?? 0);
const form = firstRow.form;
@@ -286,9 +289,29 @@ class FormService {
.map((r) => r.field)
.filter((f): f is NonNullable => !!(f && f.id));
+ // Nest each rule's conditions. The renderer needs them attached, and
+ // grouping here keeps the traversal code on the client from having to
+ // re-derive the relationship on every navigation.
+ const conditionsByRuleId = new Map<
+ string,
+ Array<(typeof conditionRows)[number]["condition"]>
+ >();
+ for (const row of conditionRows) {
+ const list = conditionsByRuleId.get(row.condition.ruleId);
+ if (list) list.push(row.condition);
+ else conditionsByRuleId.set(row.condition.ruleId, [row.condition]);
+ }
+
+ const logicRules = rules.map((rule) => ({
+ ...rule,
+ conditions: conditionsByRuleId.get(rule.id) ?? [],
+ }));
+
return {
...form,
fields,
+ segments,
+ logicRules,
submissionsCount,
role: undefined,
permissions: undefined,
@@ -354,13 +377,14 @@ class FormService {
// ─── One round-trip, one connection ──────────────────────────────────
//
// The dashboard used to fan out four queries through Promise.all. On
- // a cold pool each one had to wait its turn for a fresh TLS handshake
- // to Neon's pooler (~300ms each), which serialized into ~1.7s wall
- // time despite "running in parallel".
+ // a cold pool each one had to wait its turn for a fresh connection
+ // (~300ms each against a remote pooler, where the TLS handshake
+ // dominates), which serialized into ~1.7s wall time despite
+ // "running in parallel".
//
// Collapsing into a single statement with subqueries:
// • one pg connection acquired (no handshake contention)
- // • one Neon round-trip
+ // • one server round-trip
// • Postgres planner can share the `forms WHERE owner_id` scan
// across the four sub-aggregates instead of repeating it.
//
@@ -714,20 +738,72 @@ class FormService {
}
public async updateFormSettings(payload: UpdateFormSettingsInputType & { requesterId: string }) {
- const { id, title, description, isOpen, maxSubmissions, expiresAt } =
- await updateFormSettingsInput.parseAsync(payload);
+ const {
+ id,
+ title,
+ description,
+ isOpen,
+ expiresAt,
+ questionLayout,
+ requireSignIn,
+ collectRespondentEmail,
+ oneResponsePerRespondent,
+ allowedEmailDomains,
+ thankYouMessage,
+ } = await updateFormSettingsInput.parseAsync(payload);
const { requesterId } = payload;
await requireOwner(id, requesterId);
+ /* Normalise the domain list before storing: trim, lowercase, strip a
+ * leading "@" or scheme, drop blanks and duplicates. Authors type
+ * "@example.com" and "https://example.com" about as often as the bare
+ * domain, and storing those verbatim would produce a restriction that
+ * silently matches nobody. */
+ const domains =
+ allowedEmailDomains === undefined
+ ? undefined
+ : allowedEmailDomains === null
+ ? null
+ : [...new Set(allowedEmailDomains.map(normaliseDomain).filter(Boolean))];
+
+ /* The dependency, resolved in one place.
+ *
+ * Collecting an email, limiting to one response per account, and
+ * restricting by domain all need an account, so any of them turns on the
+ * sign-in requirement. Storing the implied value rather than deriving it on
+ * read means every consumer — the renderer, the API, a future export — sees
+ * the same answer without having to remember the rule. */
+ const impliedRequireSignIn =
+ requireSignIn === true ||
+ collectRespondentEmail === true ||
+ oneResponsePerRespondent === true ||
+ (domains?.length ?? 0) > 0;
+
await db
.update(formsTable)
.set({
title,
description: description ?? null,
isOpen,
- maxSubmissions: maxSubmissions ?? null,
expiresAt: expiresAt ? new Date(expiresAt) : null,
+ // Omitted rather than nulled when absent, so a caller that predates
+ // layouts can't silently reset the author's choice back to AUTO.
+ ...(questionLayout !== undefined ? { questionLayout } : {}),
+
+ ...(requireSignIn !== undefined ||
+ collectRespondentEmail !== undefined ||
+ oneResponsePerRespondent !== undefined ||
+ domains !== undefined
+ ? { requireSignIn: impliedRequireSignIn }
+ : {}),
+ ...(collectRespondentEmail !== undefined ? { collectRespondentEmail } : {}),
+ ...(oneResponsePerRespondent !== undefined ? { oneResponsePerRespondent } : {}),
+ ...(domains !== undefined ? { allowedEmailDomains: domains } : {}),
+ ...(thankYouMessage !== undefined
+ ? { thankYouMessage: thankYouMessage || null }
+ : {}),
+
updatedAt: new Date(),
})
.where(eq(formsTable.id, id));
diff --git a/packages/services/form/model.ts b/packages/services/form/model.ts
index fbf0002..2dd6f05 100644
--- a/packages/services/form/model.ts
+++ b/packages/services/form/model.ts
@@ -1,5 +1,7 @@
import { z } from "zod";
import { getFormFieldOutput } from "../form-field/model";
+import { getFormSegmentOutput } from "../form-segment/model";
+import { getLogicRuleOutput } from "../form-logic/model";
export type FormRole = "owner" | "editor" | "viewer";
@@ -78,6 +80,15 @@ export const formPermissionsSchema = z.object({
export type FormPermissionsType = z.infer;
+/** Mirrors `questionLayoutEnum` in packages/database/models/form.ts. */
+export const questionLayoutZodEnum = z.enum([
+ "AUTO",
+ "ONE_PER_PAGE",
+ "SEGMENT_PER_PAGE",
+ "ALL_AT_ONCE",
+]);
+export type QuestionLayout = z.infer;
+
export const getFormOutput = z.object({
id: z.string().uuid(),
title: z.string(),
@@ -86,8 +97,21 @@ export const getFormOutput = z.object({
isPublished: z.boolean(),
isArchived: z.boolean(),
isOpen: z.boolean(),
- maxSubmissions: z.number().nullable().optional(),
expiresAt: z.any().nullable().optional(),
+ questionLayout: questionLayoutZodEnum
+ .optional()
+ .describe("How many questions a respondent sees at once"),
+
+ /* Access rules. On the public payload too, because the form page has to know
+ * before the respondent starts — asking someone to fill in fifty questions
+ * and only then telling them to sign in is the version of this that wastes
+ * their time. */
+ requireSignIn: z.boolean().optional(),
+ collectRespondentEmail: z.boolean().optional(),
+ oneResponsePerRespondent: z.boolean().optional(),
+ allowedEmailDomains: z.array(z.string()).nullable().optional(),
+ thankYouMessage: z.string().nullable().optional(),
+
submissionsCount: z.number().nullable().optional(),
createdAt: z.any(),
updatedAt: z.any(),
@@ -100,6 +124,13 @@ export type GetFormOutputType = z.infer;
export const getFormByIdOutput = getFormOutput.extend({
fields: z.array(getFormFieldOutput),
+ // The renderer needs the segment list to know page boundaries and titles,
+ // and the rule list to know where each answer leads. Both are sent with
+ // the form so traversal never has to round-trip mid-flow — a respondent
+ // clicking "Next" must not wait on the network to find out which question
+ // comes next.
+ segments: z.array(getFormSegmentOutput),
+ logicRules: z.array(getLogicRuleOutput),
});
export type GetFormByIdOutputType = z.infer;
@@ -220,14 +251,35 @@ export const updateFormSettingsInput = z.object({
.optional()
.describe("Description of the form"),
isOpen: z.boolean().describe("Whether the form accepts submissions"),
- maxSubmissions: z
- .number()
- .int()
- .min(0)
+ expiresAt: z.any().nullable().optional().describe("Expiration date ISO string or Date object"),
+ questionLayout: questionLayoutZodEnum
+ .optional()
+ .describe("How many questions a respondent sees at once"),
+
+ requireSignIn: z.boolean().optional().describe("Respondents must be signed in"),
+ collectRespondentEmail: z
+ .boolean()
+ .optional()
+ .describe("Record the signed-in respondent's account email; implies requireSignIn"),
+ oneResponsePerRespondent: z
+ .boolean()
+ .optional()
+ .describe("Limit each account to one response; implies requireSignIn"),
+ allowedEmailDomains: z
+ .array(z.string().trim().min(1).max(255))
+ .max(20)
+ .optional()
.nullable()
+ .describe(
+ "Email domains permitted to respond, matched as a suffix so subdomains are included; implies requireSignIn",
+ ),
+ thankYouMessage: z
+ .string()
+ .trim()
+ .max(2000)
.optional()
- .describe("Maximum number of submissions"),
- expiresAt: z.any().nullable().optional().describe("Expiration date ISO string or Date object"),
+ .nullable()
+ .describe("Shown on the thank-you screen in addition to the default confirmation"),
});
export type UpdateFormSettingsInputType = z.infer;
diff --git a/packages/trpc/server/routes/analytics/model.ts b/packages/trpc/server/routes/analytics/model.ts
index 14c11e5..4e11c20 100644
--- a/packages/trpc/server/routes/analytics/model.ts
+++ b/packages/trpc/server/routes/analytics/model.ts
@@ -3,8 +3,8 @@ export {
getFormAnalyticsOutput as getFormAnalyticsOutputModel,
getSubmissionsListInput as getSubmissionsListInputModel,
getSubmissionsListOutput as getSubmissionsListOutputModel,
- getProAnalyticsInput,
- getProAnalyticsOutput,
+ 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
index 2ec1356..4214bc4 100644
--- a/packages/trpc/server/routes/analytics/route.ts
+++ b/packages/trpc/server/routes/analytics/route.ts
@@ -1,17 +1,12 @@
-import {
- authenticatedProcedure,
- proAuthenticatedProcedure,
- publicProcedure,
- router,
-} from "../../trpc";
+import { authenticatedProcedure, publicProcedure, router } from "../../trpc";
import { generatePath } from "../../utils/path-generator";
import {
getFormAnalyticsInputModel,
getFormAnalyticsOutputModel,
getSubmissionsListInputModel,
getSubmissionsListOutputModel,
- getProAnalyticsInput as getProAnalyticsInputModel,
- getProAnalyticsOutput as getProAnalyticsOutputModel,
+ getDetailedAnalyticsInput as getDetailedAnalyticsInputModel,
+ getDetailedAnalyticsOutput as getDetailedAnalyticsOutputModel,
recordFieldAnswerInputModel,
recordFieldAnswerOutputModel,
} from "./model";
@@ -23,7 +18,7 @@ const getPath = generatePath("/analytics");
export const analyticsRouter = router({
/**
* GET /analytics/getFormAnalytics/{formId}
- * Returns all free-tier analytics metrics for a form in a single call:
+ * Returns all summary analytics metrics for a form in a single call:
* - totalResponses
* - deviceBreakdown (desktop / mobile / tablet from submission records)
* - dailyTrends (last 30 days, zero-filled)
@@ -93,26 +88,28 @@ export const analyticsRouter = router({
}),
/**
- * GET /analytics/getProAnalytics/{formId}
- * Pro-tier: day-of-week breakdown, 30/60/90d trend totals,
- * response velocity (first 24h after publish), and per-question
- * response distribution for SELECT, CHECKBOX, RADIO, RATING, TOGGLE fields.
+ * GET /analytics/getDetailedAnalytics/{formId}
+ * Day-of-week breakdown, 30/60/90d trend totals, response velocity (first
+ * 24h after publish), and per-question response distribution for SELECT,
+ * CHECKBOX, RADIO, RATING, TOGGLE fields.
*
- * Returns FORBIDDEN for Free-tier users.
+ * Available to anyone who can see the form. This was gated behind a
+ * subscription tier; tiers no longer exist, so the ordinary authenticated
+ * procedure is the whole check.
*/
- getProAnalytics: proAuthenticatedProcedure
+ getDetailedAnalytics: authenticatedProcedure
.meta({
openapi: {
method: "GET",
- path: getPath("/getProAnalytics/{formId}"),
+ path: getPath("/getDetailedAnalytics/{formId}"),
tags: TAGS,
protect: true,
},
})
- .input(getProAnalyticsInputModel)
- .output(getProAnalyticsOutputModel)
+ .input(getDetailedAnalyticsInputModel)
+ .output(getDetailedAnalyticsOutputModel)
.query(async ({ input, ctx }) => {
- return analyticsService.getProAnalytics({
+ return analyticsService.getDetailedAnalytics({
formId: input.formId,
ownerId: ctx.user.id,
});
diff --git a/packages/trpc/server/routes/feedback/route.ts b/packages/trpc/server/routes/feedback/route.ts
index 834be10..73d603f 100644
--- a/packages/trpc/server/routes/feedback/route.ts
+++ b/packages/trpc/server/routes/feedback/route.ts
@@ -4,10 +4,7 @@ import { publicProcedure, router } from "../../trpc";
import { auth } from "../../auth";
import { feedbackService } from "../../services";
import { generatePath } from "../../utils/path-generator";
-import {
- submitFeedbackInput,
- submitFeedbackOutput,
-} from "@repo/services/feedback/model";
+import { submitFeedbackInput, submitFeedbackOutput } from "@repo/services/feedback/model";
const TAGS = ["Feedback"];
const getPath = generatePath("/feedback");
diff --git a/packages/trpc/server/routes/form/model.ts b/packages/trpc/server/routes/form/model.ts
index 2a61410..a45f09c 100644
--- a/packages/trpc/server/routes/form/model.ts
+++ b/packages/trpc/server/routes/form/model.ts
@@ -103,3 +103,34 @@ export {
submitFormInput as submitFormInputModel,
submitFormOutput as submitFormOutputModel,
} from "@repo/services/form-submission/model";
+
+export {
+ createFormSegmentInput as createFormSegmentInputModel,
+ createFormSegmentOutput as createFormSegmentOutputModel,
+ updateFormSegmentInput as updateFormSegmentInputModel,
+ updateFormSegmentOutput as updateFormSegmentOutputModel,
+ deleteFormSegmentInput as deleteFormSegmentInputModel,
+ deleteFormSegmentOutput as deleteFormSegmentOutputModel,
+ listFormSegmentsInput as listFormSegmentsInputModel,
+ listFormSegmentsOutput as listFormSegmentsOutputModel,
+} from "@repo/services/form-segment/model";
+
+export {
+ saveDraftInput as saveDraftInputModel,
+ saveDraftOutput as saveDraftOutputModel,
+ getDraftInput as getDraftInputModel,
+ getDraftOutput as getDraftOutputModel,
+ deleteDraftInput as deleteDraftInputModel,
+ deleteDraftOutput as deleteDraftOutputModel,
+} from "@repo/services/form-draft/model";
+
+export {
+ createLogicRuleInput as createLogicRuleInputModel,
+ createLogicRuleOutput as createLogicRuleOutputModel,
+ updateLogicRuleInput as updateLogicRuleInputModel,
+ updateLogicRuleOutput as updateLogicRuleOutputModel,
+ deleteLogicRuleInput as deleteLogicRuleInputModel,
+ deleteLogicRuleOutput as deleteLogicRuleOutputModel,
+ listLogicRulesInput as listLogicRulesInputModel,
+ listLogicRulesOutput as listLogicRulesOutputModel,
+} from "@repo/services/form-logic/model";
diff --git a/packages/trpc/server/routes/form/route.ts b/packages/trpc/server/routes/form/route.ts
index 80a00d7..407949f 100644
--- a/packages/trpc/server/routes/form/route.ts
+++ b/packages/trpc/server/routes/form/route.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
import { authenticatedProcedure, publicProcedure, router } from "../../trpc";
+import { auth } from "../../auth";
import { generatePath } from "../../utils/path-generator";
import {
createFormInputModel,
@@ -37,8 +38,37 @@ import {
transferOwnershipOutputModel,
updateFormSettingsInputModel,
updateFormSettingsOutputModel,
+ createFormSegmentInputModel,
+ createFormSegmentOutputModel,
+ updateFormSegmentInputModel,
+ updateFormSegmentOutputModel,
+ deleteFormSegmentInputModel,
+ deleteFormSegmentOutputModel,
+ listFormSegmentsInputModel,
+ listFormSegmentsOutputModel,
+ createLogicRuleInputModel,
+ createLogicRuleOutputModel,
+ updateLogicRuleInputModel,
+ updateLogicRuleOutputModel,
+ deleteLogicRuleInputModel,
+ deleteLogicRuleOutputModel,
+ listLogicRulesInputModel,
+ listLogicRulesOutputModel,
+ saveDraftInputModel,
+ saveDraftOutputModel,
+ getDraftInputModel,
+ getDraftOutputModel,
+ deleteDraftInputModel,
+ deleteDraftOutputModel,
} from "./model";
-import { formService, formFieldService, formSubmissionService } from "../../services";
+import {
+ formService,
+ formFieldService,
+ formSubmissionService,
+ formSegmentService,
+ formLogicService,
+ formDraftService,
+} from "../../services";
const TAGS = ["Forms"];
const getPath = generatePath("/forms");
@@ -254,9 +284,24 @@ export const formRouter = router({
})
.input(submitFormInputModel)
.output(submitFormOutputModel)
- .mutation(async ({ input }) => {
- const result = await formSubmissionService.submitForm(input);
- return result;
+ .mutation(async ({ input, ctx }) => {
+ /* Still a public procedure — most forms accept anonymous answers — but it
+ * reads the session when there is one, so a form that requires signing in
+ * can be enforced without a second endpoint.
+ *
+ * The respondent's identity is taken from the session here and passed
+ * separately from `input`. That is the point: an email in the request body
+ * is an email the sender chose, which would make the domain restriction
+ * decorative. */
+ const session = await auth.api.getSession({
+ headers: new Headers(ctx.req.headers as Record),
+ });
+
+ const respondent = session?.user
+ ? { id: session.user.id, email: session.user.email }
+ : null;
+
+ return formSubmissionService.submitForm({ ...input, respondent });
}),
listCollaborators: authenticatedProcedure
@@ -348,4 +393,180 @@ export const formRouter = router({
.mutation(async ({ input, ctx }) => {
return formService.updateFormSettings({ ...input, requesterId: ctx.user.id });
}),
+
+ /* ─── Segments ──────────────────────────────────────────────────────── */
+
+ createFormSegment: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/createFormSegment"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(createFormSegmentInputModel)
+ .output(createFormSegmentOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formSegmentService.createFormSegment({ ...input, userId: ctx.user.id });
+ }),
+
+ updateFormSegment: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/updateFormSegment"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(updateFormSegmentInputModel)
+ .output(updateFormSegmentOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formSegmentService.updateFormSegment({ ...input, userId: ctx.user.id });
+ }),
+
+ deleteFormSegment: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/deleteFormSegment"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(deleteFormSegmentInputModel)
+ .output(deleteFormSegmentOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formSegmentService.deleteFormSegment({ ...input, userId: ctx.user.id });
+ }),
+
+ listFormSegments: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "GET",
+ path: getPath("/listFormSegments"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(listFormSegmentsInputModel)
+ .output(listFormSegmentsOutputModel)
+ .query(async ({ input, ctx }) => {
+ return formSegmentService.listFormSegments({ ...input, userId: ctx.user.id });
+ }),
+
+ /* ─── Conditional branching ─────────────────────────────────────────── */
+
+ createLogicRule: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/createLogicRule"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(createLogicRuleInputModel)
+ .output(createLogicRuleOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formLogicService.createLogicRule({ ...input, userId: ctx.user.id });
+ }),
+
+ updateLogicRule: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/updateLogicRule"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(updateLogicRuleInputModel)
+ .output(updateLogicRuleOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formLogicService.updateLogicRule({ ...input, userId: ctx.user.id });
+ }),
+
+ deleteLogicRule: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/deleteLogicRule"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(deleteLogicRuleInputModel)
+ .output(deleteLogicRuleOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formLogicService.deleteLogicRule({ ...input, userId: ctx.user.id });
+ }),
+
+ listLogicRules: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "GET",
+ path: getPath("/listLogicRules"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(listLogicRulesInputModel)
+ .output(listLogicRulesOutputModel)
+ .query(async ({ input, ctx }) => {
+ return formLogicService.listLogicRules({ ...input, userId: ctx.user.id });
+ }),
+
+ /* ─── Drafts ─────────────────────────────────────────────────────────
+ *
+ * Authenticated, and scoped to `ctx.user.id` rather than to anything in the
+ * input — a draft is personal, and the caller can only ever reach their own.
+ * An anonymous respondent has no stable identity between sessions, so there
+ * is deliberately no public equivalent. */
+
+ saveDraft: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/saveDraft"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(saveDraftInputModel)
+ .output(saveDraftOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formDraftService.saveDraft({ ...input, userId: ctx.user.id });
+ }),
+
+ getDraft: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "GET",
+ path: getPath("/getDraft/{formId}"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(getDraftInputModel)
+ .output(getDraftOutputModel)
+ .query(async ({ input, ctx }) => {
+ return formDraftService.getDraft({ ...input, userId: ctx.user.id });
+ }),
+
+ deleteDraft: authenticatedProcedure
+ .meta({
+ openapi: {
+ method: "POST",
+ path: getPath("/deleteDraft"),
+ tags: TAGS,
+ protect: true,
+ },
+ })
+ .input(deleteDraftInputModel)
+ .output(deleteDraftOutputModel)
+ .mutation(async ({ input, ctx }) => {
+ return formDraftService.deleteDraft({ ...input, userId: ctx.user.id });
+ }),
});
diff --git a/packages/trpc/server/routes/user/route.ts b/packages/trpc/server/routes/user/route.ts
index 1c43aec..39be5e3 100644
--- a/packages/trpc/server/routes/user/route.ts
+++ b/packages/trpc/server/routes/user/route.ts
@@ -34,7 +34,6 @@ const meOutput = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
- plan: z.enum(["Free", "Pro", "Pro+", "Business"]),
/** Either one of AVATAR_PRESETS, a provider URL, or null. */
image: z.string().nullable(),
/**
@@ -54,7 +53,6 @@ type UserRow = {
id: string;
name: string;
email: string;
- plan: "Free" | "Pro" | "Pro+" | "Business";
image: string | null;
createdAt: Date;
};
@@ -63,7 +61,6 @@ const toMe = (user: UserRow) => ({
id: user.id,
name: user.name,
email: user.email,
- plan: user.plan,
image: user.image,
createdAt: user.createdAt.toISOString(),
});
@@ -71,8 +68,8 @@ const toMe = (user: UserRow) => ({
export const userRouter = router({
/**
* GET /user/me
- * Returns the current authenticated user's id, name, email, subscription
- * plan, avatar preset and signup date.
+ * Returns the current authenticated user's id, name, email, avatar preset
+ * and signup date.
*/
getMe: authenticatedProcedure
.meta({
@@ -94,7 +91,6 @@ export const userRouter = router({
id: users.id,
name: users.name,
email: users.email,
- plan: users.plan,
image: users.image,
createdAt: users.createdAt,
})
@@ -112,8 +108,8 @@ export const userRouter = router({
* Updates the caller's own display name and/or avatar preset.
*
* Scoped to `ctx.user.id` — there is no user id in the input, so this cannot
- * be pointed at another account. `plan` and `email` are deliberately not
- * editable: one is billing state and the other is an auth identifier.
+ * be pointed at another account. `email` is deliberately not editable: it's
+ * an auth identifier.
*/
updateMe: authenticatedProcedure
.meta({
@@ -146,18 +142,13 @@ export const userRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "Nothing to update" });
}
- const rows = await db
- .update(users)
- .set(patch)
- .where(dbEq(users.id, ctx.user.id))
- .returning({
- id: users.id,
- name: users.name,
- email: users.email,
- plan: users.plan,
- image: users.image,
- createdAt: users.createdAt,
- });
+ const rows = await db.update(users).set(patch).where(dbEq(users.id, ctx.user.id)).returning({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ image: users.image,
+ createdAt: users.createdAt,
+ });
const user = rows[0];
if (!user) throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
diff --git a/packages/trpc/server/services/index.ts b/packages/trpc/server/services/index.ts
index 4cca3e2..19954ab 100644
--- a/packages/trpc/server/services/index.ts
+++ b/packages/trpc/server/services/index.ts
@@ -1,11 +1,17 @@
import FormService from "@repo/services/form";
import FormFieldService from "@repo/services/form-field";
+import FormSegmentService from "@repo/services/form-segment";
+import FormLogicService from "@repo/services/form-logic";
+import FormDraftService from "@repo/services/form-draft";
import FormSubmissionService from "@repo/services/form-submission";
import AnalyticsService from "@repo/services/analytics";
import FeedbackService from "@repo/services/feedback";
export const formService = new FormService();
export const formFieldService = new FormFieldService();
+export const formSegmentService = new FormSegmentService();
+export const formLogicService = new FormLogicService();
+export const formDraftService = new FormDraftService();
export const formSubmissionService = new FormSubmissionService();
export const analyticsService = new AnalyticsService();
export const feedbackService = new FeedbackService();
diff --git a/packages/trpc/server/trpc.ts b/packages/trpc/server/trpc.ts
index 4e0ca73..b799d44 100644
--- a/packages/trpc/server/trpc.ts
+++ b/packages/trpc/server/trpc.ts
@@ -52,37 +52,3 @@ export const authenticatedProcedure = tRPCContext.procedure.use(async (options)
return result;
});
-
-/**
- * Procedure that requires Pro+ or Business tier.
- * Fetches the user's plan from DB and throws FORBIDDEN for Free and Pro users.
- */
-export const proAuthenticatedProcedure = authenticatedProcedure.use(async (options) => {
- const { ctx } = options;
-
- // Import eq from @repo/database to avoid drizzle version mismatch
- const { eq: dbEq, usersTable: users } = await import("@repo/database");
- const { db: database } = await import("@repo/database");
-
- const rows = await database
- .select({ plan: users.plan })
- .from(users)
- .where(dbEq(users.id, ctx.user.id));
-
- const plan = rows[0]?.plan ?? "Free";
- const hasDetailedAnalytics = plan === "Pro+" || plan === "Business";
-
- if (!hasDetailedAnalytics) {
- throw new TRPCError({
- code: "FORBIDDEN",
- message: "Upgrade to Pro+ or Business to access detailed analytics",
- });
- }
-
- return options.next({
- ctx: {
- ...ctx,
- user: { ...ctx.user, plan },
- },
- });
-});
diff --git a/turbo.json b/turbo.json
index b62783f..fe07711 100644
--- a/turbo.json
+++ b/turbo.json
@@ -17,7 +17,11 @@
"cache": false,
"persistent": true
},
- "db:generate": {},
- "db:migrate": {}
+ "db:generate": {
+ "cache": false
+ },
+ "db:migrate": {
+ "cache": false
+ }
}
}
From 9b5afedd44d2ea0393b3f4488b651cfd4fc4811b Mon Sep 17 00:00:00 2001
From: thedittya471
Date: Mon, 3 Aug 2026 02:42:07 +0530
Subject: [PATCH 6/7] feat: implement file upload functionality, redis caching,
and background worker infrastructure
---
.env.example | 103 +-
apps/api/package.json | 10 +-
apps/api/src/env.ts | 19 +-
apps/api/src/index.ts | 143 +-
apps/api/src/lib/rate-limit-store.ts | 18 +
apps/api/src/routes/upload.ts | 304 +++
apps/api/src/server.ts | 76 +-
apps/api/tsconfig.json | 1 -
apps/web/app/(auth)/layout.tsx | 6 +-
apps/web/app/(auth)/signIn/page.tsx | 31 +-
apps/web/app/(auth)/signUp/page.tsx | 16 +-
apps/web/app/dashboard/analytics/page.tsx | 6 -
apps/web/app/dashboard/layout.tsx | 131 +-
apps/web/app/dashboard/page.tsx | 20 +-
apps/web/app/dashboard/profile/page.tsx | 5 -
.../app/dashboard/sketches/[formId]/page.tsx | 1529 +----------
apps/web/app/dashboard/sketches/page.tsx | 105 +-
apps/web/app/forms/[formId]/page.tsx | 293 +--
apps/web/app/globals.css | 264 +-
apps/web/app/learn-more/page.tsx | 4 -
apps/web/app/not-found.tsx | 1 -
apps/web/app/page.tsx | 94 +-
apps/web/app/privacy/page.tsx | 29 -
apps/web/app/security/page.tsx | 39 -
apps/web/app/terms/page.tsx | 25 -
apps/web/components/DashboardNav.tsx | 32 -
apps/web/components/FeedbackWidget.tsx | 21 -
apps/web/components/Footer.tsx | 2 -
apps/web/components/Navbar.tsx | 33 -
apps/web/components/Noise.tsx | 26 -
apps/web/components/NotFoundPanel.tsx | 38 -
.../components/analytics/DeviceBreakdown.tsx | 8 -
.../components/analytics/EngagementStats.tsx | 4 -
.../web/components/analytics/FieldDropoff.tsx | 18 -
apps/web/components/analytics/MetricsGrid.tsx | 13 -
.../components/analytics/PeriodComparison.tsx | 8 -
.../analytics/QuestionDistribution.tsx | 15 -
.../analytics/SubmissionDetailModal.tsx | 94 +-
.../components/analytics/SubmissionsTable.tsx | 24 +-
apps/web/components/analytics/palette.ts | 29 -
apps/web/components/auth/AuthFields.tsx | 14 -
apps/web/components/builder/BuilderHeader.tsx | 8 -
.../web/components/builder/FieldInspector.tsx | 180 +-
apps/web/components/builder/FieldOutline.tsx | 24 -
apps/web/components/builder/FieldSidebar.tsx | 13 +-
apps/web/components/builder/FormFieldNode.tsx | 26 +-
.../components/builder/FormSettingsDialog.tsx | 77 +-
apps/web/components/builder/LogicDialog.tsx | 197 +-
apps/web/components/builder/SegmentPanel.tsx | 34 -
.../builder/ShareCollaboratorsDialog.tsx | 237 +-
.../builder/mobile/MobileFieldEditorSheet.tsx | 5 -
.../web/components/builder/useBuilderState.ts | 1358 ++++++++++
apps/web/components/docs/DocsSectionNav.tsx | 39 -
apps/web/components/forms/FileUploadField.tsx | 373 +++
apps/web/components/forms/FormDraftNotice.tsx | 10 -
apps/web/components/forms/FormErrorState.tsx | 37 +-
apps/web/components/forms/FormHeader.tsx | 13 -
.../components/forms/FormPreviewBanner.tsx | 9 -
apps/web/components/forms/FormQuestion.tsx | 81 +-
apps/web/components/forms/FormThankYou.tsx | 35 +-
.../components/landing/CanvasFlowMockup.tsx | 40 -
apps/web/components/ui/CustomSelect.tsx | 113 +
apps/web/components/ui/ModalOverlay.tsx | 48 +-
apps/web/env.js | 23 -
apps/web/hooks/api/analytics/index.ts | 43 -
apps/web/hooks/api/feedback/index.ts | 8 -
apps/web/hooks/api/form/index.ts | 16 -
apps/web/hooks/api/user/index.ts | 8 -
apps/web/hooks/use-toasts.ts | 7 -
apps/web/hooks/useAnimatedCounter.ts | 1 -
apps/web/hooks/useDebounce.ts | 21 -
apps/web/hooks/useDebouncedCallback.ts | 20 -
apps/web/lib/form-access.ts | 55 +-
apps/web/lib/form-flow.ts | 243 --
apps/web/lib/form-logic.ts | 48 -
apps/web/lib/fractional-index.ts | 56 -
apps/web/lib/upload.ts | 277 ++
apps/web/lib/utils.ts | 19 -
apps/web/middleware.ts | 4 +-
apps/web/providers/global.tsx | 3 -
apps/worker/eslint.config.js | 11 +
apps/worker/package.json | 33 +
apps/worker/src/env.ts | 25 +
apps/worker/src/index.ts | 94 +
apps/worker/src/processors/analytics.ts | 25 +
apps/worker/src/processors/upload.ts | 74 +
apps/worker/src/services.ts | 4 +
apps/worker/src/storage.ts | 142 +
apps/worker/tsconfig.json | 16 +
apps/worker/tsup.config.ts | 17 +
docker-compose.yml | 44 +-
.../drizzle/0020_dashing_santa_claus.sql | 27 +
.../database/drizzle/meta/0020_snapshot.json | 2285 +++++++++++++++++
packages/database/drizzle/meta/_journal.json | 7 +
packages/database/env.ts | 24 +-
packages/database/index.ts | 67 +-
packages/database/migrate.mjs | 13 -
packages/database/models/auth.ts | 2 -
packages/database/models/feedback.ts | 4 -
packages/database/models/form-draft.ts | 34 -
packages/database/models/form-field-view.ts | 7 -
packages/database/models/form-field.ts | 20 -
packages/database/models/form-logic.ts | 101 -
packages/database/models/form-segment.ts | 19 -
packages/database/models/form-submission.ts | 40 +-
packages/database/models/form-upload.ts | 80 +
packages/database/models/form.ts | 45 -
packages/database/package.json | 3 +-
packages/database/schema.ts | 1 +
packages/logger/package.json | 4 +-
packages/queue/env.ts | 24 +
packages/queue/index.ts | 150 ++
packages/queue/jobs.ts | 23 +
packages/queue/package.json | 22 +
packages/queue/tsconfig.json | 3 +
packages/redis/cache.ts | 76 +
packages/redis/env.ts | 14 +
packages/redis/index.ts | 102 +
packages/redis/package.json | 18 +
packages/redis/tsconfig.json | 3 +
packages/services/analytics/index.ts | 97 +-
packages/services/analytics/model.ts | 27 +-
packages/services/feedback/index.ts | 28 +-
packages/services/feedback/model.ts | 14 +-
packages/services/form-draft/index.ts | 24 +-
packages/services/form-draft/model.ts | 5 -
packages/services/form-field/index.ts | 35 +-
packages/services/form-field/model.ts | 12 -
packages/services/form-logic/index.ts | 64 +-
packages/services/form-logic/model.ts | 45 +-
packages/services/form-segment/index.ts | 71 +-
packages/services/form-segment/model.ts | 17 -
packages/services/form-submission/access.ts | 55 +-
packages/services/form-submission/index.ts | 257 +-
packages/services/form-submission/model.ts | 16 -
packages/services/form-upload/index.ts | 385 +++
packages/services/form-upload/model.ts | 72 +
packages/services/form/index.ts | 245 +-
packages/services/form/model.ts | 16 -
packages/services/package.json | 5 +
packages/trpc/package.json | 4 +
packages/trpc/server/auth.ts | 49 +-
.../trpc/server/routes/analytics/route.ts | 32 +-
packages/trpc/server/routes/feedback/route.ts | 24 +-
packages/trpc/server/routes/form/route.ts | 24 +-
packages/trpc/server/routes/user/route.ts | 43 +-
packages/trpc/server/services/index.ts | 2 +
packages/trpc/server/trpc.ts | 7 +-
pnpm-lock.yaml | 415 ++-
149 files changed, 8167 insertions(+), 5086 deletions(-)
create mode 100644 apps/api/src/lib/rate-limit-store.ts
create mode 100644 apps/api/src/routes/upload.ts
create mode 100644 apps/web/components/builder/useBuilderState.ts
create mode 100644 apps/web/components/forms/FileUploadField.tsx
create mode 100644 apps/web/components/ui/CustomSelect.tsx
create mode 100644 apps/web/lib/upload.ts
create mode 100644 apps/worker/eslint.config.js
create mode 100644 apps/worker/package.json
create mode 100644 apps/worker/src/env.ts
create mode 100644 apps/worker/src/index.ts
create mode 100644 apps/worker/src/processors/analytics.ts
create mode 100644 apps/worker/src/processors/upload.ts
create mode 100644 apps/worker/src/services.ts
create mode 100644 apps/worker/src/storage.ts
create mode 100644 apps/worker/tsconfig.json
create mode 100644 apps/worker/tsup.config.ts
create mode 100644 packages/database/drizzle/0020_dashing_santa_claus.sql
create mode 100644 packages/database/drizzle/meta/0020_snapshot.json
create mode 100644 packages/database/models/form-upload.ts
create mode 100644 packages/queue/env.ts
create mode 100644 packages/queue/index.ts
create mode 100644 packages/queue/jobs.ts
create mode 100644 packages/queue/package.json
create mode 100644 packages/queue/tsconfig.json
create mode 100644 packages/redis/cache.ts
create mode 100644 packages/redis/env.ts
create mode 100644 packages/redis/index.ts
create mode 100644 packages/redis/package.json
create mode 100644 packages/redis/tsconfig.json
create mode 100644 packages/services/form-upload/index.ts
create mode 100644 packages/services/form-upload/model.ts
diff --git a/.env.example b/.env.example
index feb9cdd..62d0d09 100644
--- a/.env.example
+++ b/.env.example
@@ -1,81 +1,64 @@
-# =============================================================================
-# CanvasFlow Environment Variables
-# =============================================================================
-#
-# Copy this file to `.env` at the repo root and fill in the values.
-# `setup.sh` will hard-link the root .env into every workspace
-# (apps/* and packages/*) so all processes share one source of truth.
-#
-# cp .env.example .env
-# bash setup.sh
-
-# -----------------------------------------------------------------------------
-# API Server (apps/api)
-# -----------------------------------------------------------------------------
+# API Server
PORT=8000
NODE_ENV=development
BASE_URL=http://localhost:8000
-# -----------------------------------------------------------------------------
-# Database (packages/database)
-# PostgreSQL connection string.
-# Format: postgresql://:@:/
-#
-# Default: local Postgres from the bundled docker-compose.yml.
-# pnpm db:up # start it (waits until it accepts connections)
-# pnpm db:migrate # apply the drizzle migrations
-#
-# POSTGRES_PORT is the HOST port compose publishes; it must match the port
-# in DATABASE_URL. It defaults to 5434 rather than 5432 so it can coexist
-# with other Postgres containers already running on the machine.
-#
-# To point at a managed provider instead, replace DATABASE_URL with its
-# connection string (most require TLS, e.g. `?sslmode=require`) and skip
-# `pnpm db:up`.
-# -----------------------------------------------------------------------------
+# Database
DATABASE_URL=postgresql://postgres:postgres@localhost:5434/dev
POSTGRES_PORT=5434
+DB_POOL_MAX=25
+DB_STATEMENT_TIMEOUT_MS=10000
+
+# Clustering
+CLUSTER_WORKERS=0
+
+# Rate Limiting
+RATE_LIMIT_PUBLIC_WRITE_MAX=60
+RATE_LIMIT_AUTH_MAX=300
+RATE_LIMIT_UPLOAD_MAX=30
+
+# Redis
+REDIS_URL=redis://localhost:6379
+REDIS_PORT=6379
+REDIS_PREFIX=cf
+
+# Cloudinary
+CLOUDINARY_CLOUD_NAME=
+CLOUDINARY_API_KEY=
+CLOUDINARY_API_SECRET=
+CLOUDINARY_FOLDER=canvasflow
+
+# File Uploads
+UPLOAD_TMP_DIR=
+UPLOAD_MAX_MB=10
+UPLOAD_MAX_MB_IMAGE=10
+UPLOAD_MAX_MB_VIDEO=100
+UPLOAD_MAX_MB_RAW=10
+UPLOAD_WORKER_CONCURRENCY=8
+
+# Analytics Queue
+ANALYTICS_BATCH_MS=250
+ANALYTICS_BATCH_MAX=500
+ANALYTICS_WORKER_CONCURRENCY=4
+ANALYTICS_INSERT_CHUNK=500
-# -----------------------------------------------------------------------------
-# Better Auth (packages/trpc/server/auth.ts)
-# BETTER_AUTH_SECRET signs sessions/tokens — change it before deploying.
-# Generate one with: openssl rand -base64 32
-# -----------------------------------------------------------------------------
+# Better Auth
BETTER_AUTH_SECRET=replace-me-with-an-openssl-rand-base64-32-value
BETTER_AUTH_URL=http://localhost:8000
WEB_URL=http://localhost:3000
-# -----------------------------------------------------------------------------
-# Google OAuth (optional)
-# Both keys must be set to enable the "Continue with Google" button;
-# otherwise the server skips the provider gracefully.
-# Get credentials at: https://console.cloud.google.com/apis/credentials
-# -----------------------------------------------------------------------------
+# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
-# -----------------------------------------------------------------------------
-# GitHub OAuth (optional)
-# Same all-or-nothing rule as Google.
-# Register an OAuth app at: https://github.com/settings/developers
-# -----------------------------------------------------------------------------
+# GitHub OAuth
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
-# -----------------------------------------------------------------------------
-# Web App (apps/web)
-# -----------------------------------------------------------------------------
+# Web App
NEXT_PUBLIC_API_URL=http://localhost:8000
-# -----------------------------------------------------------------------------
-# Production deployment (subdomain split: web on `canvasflow.`,
-# api on `api.canvasflow.`). Leave blank in development.
-# -----------------------------------------------------------------------------
-# Extra origins allowed by CORS and Better Auth (comma-separated).
-# Example: TRUSTED_ORIGINS=https://canvasflow.dittya.dev
+# Production Split / CORS
TRUSTED_ORIGINS=
-# Cookie domain shared by web + api subdomains so the auth session is
-# readable across both. Leave unset in dev — letting it default to the
-# request host is correct for localhost.
-# Example: COOKIE_DOMAIN=.canvasflow.dittya.dev
COOKIE_DOMAIN=
+SKIP_ENV_VALIDATION=
diff --git a/apps/api/package.json b/apps/api/package.json
index 4338299..6780ee6 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -7,7 +7,8 @@
"start": "node dist/index.js",
"dev": "dotenv -- tsx watch ./src/index.ts",
"build": "tsup",
- "lint": "eslint src/*"
+ "lint": "eslint src/*",
+ "check-types": "tsc --noEmit"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
@@ -17,6 +18,7 @@
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
+ "@types/multer": "^1.4.12",
"@types/node": "^22.15.3",
"dotenv-cli": "^11.0.0",
"tsup": "^8.5.1",
@@ -25,7 +27,11 @@
},
"dependencies": {
"@better-auth/drizzle-adapter": "^1.6.20",
+ "@repo/database": "workspace:*",
"@repo/logger": "workspace:*",
+ "@repo/queue": "workspace:*",
+ "@repo/redis": "workspace:*",
+ "@repo/services": "workspace:*",
"@repo/trpc": "workspace:*",
"@scalar/express-api-reference": "^0.8.30",
"@trpc/server": "^11.8.1",
@@ -37,7 +43,9 @@
"drizzle-orm": "^0.45.1",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
+ "multer": "2.0.2",
"pg": "^8.16.3",
+ "rate-limit-redis": "4.2.0",
"trpc-to-openapi": "^3.1.0",
"winston": "^3.19.0",
"zod": "^4.3.5"
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index 10b7a09..2f6d9c6 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -4,17 +4,18 @@ const envSchema = z.object({
PORT: z.string().optional(),
NODE_ENV: z.string().default("development"),
BASE_URL: z.string().default("http://localhost:8000"),
- // Comma-separated list of additional origins to allow for CORS and
- // Better Auth's `trustedOrigins`. Set this on the VM via .env so the
- // production web subdomain can talk to the API.
- // TRUSTED_ORIGINS=https://canvasflow.dittya.dev
TRUSTED_ORIGINS: z.string().optional(),
- // Cookie domain shared between the web and api subdomains so the
- // Better Auth session cookie set by /api/auth/* on `api.canvasflow.…`
- // is readable from `canvasflow.…`. Leading dot is conventional but
- // not required by RFC 6265.
- // COOKIE_DOMAIN=.canvasflow.dittya.dev
COOKIE_DOMAIN: z.string().optional(),
+ UPLOAD_TMP_DIR: z.string().optional(),
+ UPLOAD_MAX_MB: z.coerce.number().positive().max(1_024).optional().default(10),
+ UPLOAD_MAX_MB_IMAGE: z.coerce.number().positive().max(1_024).optional().default(10),
+ UPLOAD_MAX_MB_VIDEO: z.coerce.number().positive().max(4_096).optional().default(100),
+ UPLOAD_MAX_MB_RAW: z.coerce.number().positive().max(1_024).optional().default(10),
+ DB_POOL_MAX: z.coerce.number().int().min(1).max(500).optional().default(25),
+ CLUSTER_WORKERS: z.coerce.number().int().min(0).max(64).optional().default(0),
+ RATE_LIMIT_PUBLIC_WRITE_MAX: z.coerce.number().int().min(1).optional().default(60),
+ RATE_LIMIT_AUTH_MAX: z.coerce.number().int().min(1).optional().default(300),
+ RATE_LIMIT_UPLOAD_MAX: z.coerce.number().int().min(1).optional().default(30),
});
function createEnv(env: NodeJS.ProcessEnv) {
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index de233ab..79ad126 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -7,19 +7,148 @@ if (!globalThis.crypto) {
});
}
+import cluster from "node:cluster";
import http from "node:http";
+import os from "node:os";
+
import { logger } from "@repo/logger";
-import { app as expressApplication } from "./server";
+import { closeDb } from "@repo/database";
+import { closeRedis } from "@repo/redis";
+import { drainProducers } from "@repo/queue";
+import { app as expressApplication } from "./server";
import { env } from "./env";
-async function init() {
- try {
- const server = http.createServer(expressApplication);
- const PORT: number = env.PORT ? +env.PORT : 8000;
- server.listen(PORT, () => {
- logger.info(`http server is running on PORT ${PORT}`);
+const PORT: number = env.PORT ? +env.PORT : 8000;
+const requestedWorkers = env.CLUSTER_WORKERS;
+const workerCount = Math.min(requestedWorkers, os.availableParallelism?.() ?? os.cpus().length);
+
+function warnOnConnectionBudget() {
+ const processes = Math.max(workerCount, 1);
+ const total = processes * env.DB_POOL_MAX;
+
+ const COMMON_DEFAULT_MAX = 100;
+
+ if (total > COMMON_DEFAULT_MAX) {
+ logger.warn(
+ `database connection budget: ${processes} process(es) x DB_POOL_MAX=${env.DB_POOL_MAX} = ` +
+ `${total} connections. Postgres allows 100 by default — if it hasn't been raised, ` +
+ `expect "too many clients already" under load. Either raise max_connections, lower ` +
+ `DB_POOL_MAX to <= ${Math.floor(COMMON_DEFAULT_MAX / processes)}, or put pgBouncer in front.`,
+ );
+ }
+}
+
+function startServer() {
+ const server = http.createServer(expressApplication);
+ server.keepAliveTimeout = 65_000;
+ server.headersTimeout = 66_000;
+
+ server.requestTimeout = 0;
+ const BACKLOG = 2048;
+
+ server.on("error", (err: NodeJS.ErrnoException) => {
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") {
+ logger.error(`cannot bind port ${PORT}: ${err.code}. Exiting.`);
+ process.exit(1);
+ }
+ logger.error(`http server error: ${err.message}`);
+ });
+
+ server.listen(PORT, BACKLOG, () => {
+ logger.info(
+ `http server listening on ${PORT}` +
+ (cluster.isWorker ? ` (worker ${process.pid})` : " (single process)"),
+ );
+ });
+
+ let shuttingDown = false;
+
+ const shutdown = async (signal: string) => {
+ if (shuttingDown) return;
+ shuttingDown = true;
+
+ logger.info(`${signal} received — draining connections...`);
+
+ const forceExit = setTimeout(() => {
+ logger.error("shutdown timed out — exiting");
+ process.exit(1);
+ }, 25_000);
+ forceExit.unref();
+
+ server.close(async () => {
+ try {
+ await drainProducers();
+ await Promise.allSettled([closeRedis(), closeDb()]);
+ logger.info("shutdown complete");
+ process.exit(0);
+ } catch (err) {
+ logger.error(`error during shutdown: ${err instanceof Error ? err.message : err}`);
+ process.exit(1);
+ }
});
+ };
+
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
+ process.on("SIGINT", () => void shutdown("SIGINT"));
+ process.on("unhandledRejection", (reason) => {
+ logger.error(`unhandled promise rejection: ${reason instanceof Error ? reason.stack : reason}`);
+ });
+
+ process.on("uncaughtException", (err) => {
+ logger.error(`uncaught exception: ${err.stack ?? err.message}`);
+ });
+}
+
+function init() {
+ try {
+ if (workerCount > 1 && cluster.isPrimary) {
+ logger.info(`primary ${process.pid} forking ${workerCount} workers`);
+ warnOnConnectionBudget();
+
+ for (let i = 0; i < workerCount; i += 1) cluster.fork();
+
+ let primaryShuttingDown = false;
+
+ cluster.on("exit", (worker, code, signal) => {
+ if (primaryShuttingDown || worker.exitedAfterDisconnect) return;
+
+ logger.error(
+ `worker ${worker.process.pid} died (${signal || code}) — forking a replacement`,
+ );
+ cluster.fork();
+ });
+
+ const relay = (signal: NodeJS.Signals) => {
+ if (primaryShuttingDown) return;
+ primaryShuttingDown = true;
+
+ logger.info(`primary ${process.pid} relaying ${signal} to ${workerCount} worker(s)`);
+ for (const worker of Object.values(cluster.workers ?? {})) worker?.kill(signal);
+
+ const giveUp = setTimeout(() => {
+ logger.error("workers did not exit in time — primary exiting");
+ process.exit(1);
+ }, 28_000);
+ giveUp.unref();
+
+ const waitForWorkers = setInterval(() => {
+ if (Object.keys(cluster.workers ?? {}).length === 0) {
+ clearInterval(waitForWorkers);
+ logger.info("all workers stopped — primary exiting");
+ process.exit(0);
+ }
+ }, 200);
+ };
+
+ process.on("SIGTERM", () => relay("SIGTERM"));
+ process.on("SIGINT", () => relay("SIGINT"));
+
+ return;
+ }
+
+ if (!cluster.isWorker) warnOnConnectionBudget();
+ startServer();
} catch (err) {
logger.error(`Error creating http server`, { err });
process.exit(1);
diff --git a/apps/api/src/lib/rate-limit-store.ts b/apps/api/src/lib/rate-limit-store.ts
new file mode 100644
index 0000000..360dfe0
--- /dev/null
+++ b/apps/api/src/lib/rate-limit-store.ts
@@ -0,0 +1,18 @@
+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/routes/upload.ts b/apps/api/src/routes/upload.ts
new file mode 100644
index 0000000..2e7c680
--- /dev/null
+++ b/apps/api/src/routes/upload.ts
@@ -0,0 +1,304 @@
+import crypto from "node:crypto";
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import os from "node:os";
+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";
+
+export const uploadRouter: express.Router = express.Router();
+
+const TMP_DIR = env.UPLOAD_TMP_DIR?.trim() || path.join(os.tmpdir(), "canvasflow-uploads");
+fs.mkdirSync(TMP_DIR, { recursive: true });
+logger.info(`[upload] temp directory: ${TMP_DIR}`);
+
+const MAX_BYTES = Math.floor(env.UPLOAD_MAX_MB * 1024 * 1024);
+
+const DEFAULT_ALLOWED_MIME = new Set([
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/heic",
+ "image/heif",
+ "image/avif",
+ "application/pdf",
+ "text/plain",
+ "text/csv",
+ "application/msword",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "application/vnd.ms-excel",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ "application/vnd.ms-powerpoint",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "application/zip",
+ "application/json",
+ "video/mp4",
+ "video/quicktime",
+ "audio/mpeg",
+ "audio/mp4",
+ "audio/wav",
+]);
+
+function matchesAcceptEntry(mimeType: string, fileName: string, entry: string): boolean {
+ const rule = entry.trim().toLowerCase();
+ if (!rule) return false;
+
+ if (rule.startsWith(".")) return fileName.toLowerCase().endsWith(rule);
+ if (rule.endsWith("/*")) return mimeType.startsWith(rule.slice(0, -1));
+ return mimeType === rule;
+}
+
+function safeExtension(originalName: string): string {
+ const raw = path.extname(originalName).slice(1).toLowerCase();
+ const cleaned = raw.replace(/[^a-z0-9]/g, "").slice(0, 10);
+ return cleaned ? `.${cleaned}` : "";
+}
+
+const storage = multer.diskStorage({
+ destination: (_req, _file, cb) => cb(null, TMP_DIR),
+ filename: (_req, file, cb) => {
+ cb(null, `${crypto.randomUUID()}${safeExtension(file.originalname)}`);
+ },
+});
+
+interface UploadLocals {
+ accept?: string[];
+ maxMb?: number;
+}
+
+const upload = multer({
+ storage,
+ limits: {
+ fileSize: MAX_BYTES,
+ files: 1,
+ fields: 4,
+ parts: 8,
+ },
+ fileFilter: (req, file, cb) => {
+ const locals = (req as Request & { uploadRules?: UploadLocals }).uploadRules ?? {};
+ const mimeType = (file.mimetype || "").toLowerCase();
+
+ if (!DEFAULT_ALLOWED_MIME.has(mimeType)) {
+ return cb(new MulterError("LIMIT_UNEXPECTED_FILE", `Unsupported file type: ${mimeType}`));
+ }
+
+ if (locals.accept && locals.accept.length > 0) {
+ const permitted = locals.accept.some((entry) =>
+ matchesAcceptEntry(mimeType, file.originalname, entry),
+ );
+ if (!permitted) {
+ return cb(
+ new MulterError("LIMIT_UNEXPECTED_FILE", "This question doesn't accept that file type"),
+ );
+ }
+ }
+
+ return cb(null, true);
+ },
+});
+
+const uploadLimiter = rateLimit({
+ windowMs: 60_000,
+ max: env.RATE_LIMIT_UPLOAD_MAX,
+ standardHeaders: "draft-7",
+ legacyHeaders: false,
+ keyGenerator: (req) => ipKeyGenerator(req.ip ?? "unknown"),
+ store: redisRateLimitStore("upload"),
+ message: { error: "Too many uploads — wait a minute and try again." },
+});
+
+async function discard(filePath: string | undefined): Promise {
+ if (!filePath) return;
+ try {
+ await fsp.unlink(filePath);
+ } catch {
+ /* Already gone, or never written. Either way there is nothing to clean up
+ * and nothing worth failing the request over. */
+ }
+}
+
+async function preflight(req: Request, res: Response, next: NextFunction) {
+ const { formId, fieldId } = req.params as { formId?: string; fieldId?: string };
+
+ if (!formId || !fieldId) {
+ return res.status(400).json({ error: "Missing form or field id" });
+ }
+
+ if (!isQueueAvailable()) {
+ return res.status(503).json({
+ error: "File uploads are temporarily unavailable. Please try again shortly.",
+ });
+ }
+
+ try {
+ const rules = await formUploadService.assertUploadAllowed({
+ formId,
+ formFieldId: fieldId,
+ });
+
+ (req as Request & { uploadRules?: UploadLocals }).uploadRules = {
+ ...(rules.accept ? { accept: rules.accept } : {}),
+ ...(rules.maxMb ? { maxMb: Math.min(rules.maxMb, env.UPLOAD_MAX_MB) } : {}),
+ };
+
+ return next();
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Upload not allowed";
+ const status = message === "Upload target not found" ? 404 : 403;
+ return res.status(status).json({ error: message });
+ }
+}
+
+uploadRouter.post(
+ "/uploads/:formId/:fieldId",
+ uploadLimiter,
+ preflight,
+ upload.single("file"),
+ async (req: Request, res: Response) => {
+ const file = req.file;
+ if (!file) {
+ return res.status(400).json({ error: "No file was uploaded" });
+ }
+
+ const { formId, fieldId } = req.params as { formId: string; fieldId: string };
+ const rules = (req as Request & { uploadRules?: UploadLocals }).uploadRules ?? {};
+ const kind = storageKindFor(file.mimetype);
+ const providerCapMb =
+ kind === "image"
+ ? env.UPLOAD_MAX_MB_IMAGE
+ : kind === "video"
+ ? env.UPLOAD_MAX_MB_VIDEO
+ : env.UPLOAD_MAX_MB_RAW;
+
+ const effectiveMb = Math.min(rules.maxMb ?? env.UPLOAD_MAX_MB, providerCapMb);
+
+ if (file.size > effectiveMb * 1024 * 1024) {
+ await discard(file.path);
+
+ const actualMb = (file.size / (1024 * 1024)).toFixed(1);
+ return res.status(413).json({
+ error:
+ `That file is ${actualMb}MB, which is over the ${effectiveMb}MB limit for ` +
+ `${kind === "raw" ? "documents" : kind === "image" ? "images" : "audio and video"}.`,
+ });
+ }
+
+ let uploadId: string | undefined;
+
+ try {
+ const created = await formUploadService.createPendingUpload({
+ formId,
+ formFieldId: fieldId,
+ originalName: file.originalname,
+ mimeType: file.mimetype,
+ sizeBytes: file.size,
+ storedPath: file.path,
+ });
+ uploadId = created.uploadId;
+
+ await enqueueUpload({
+ uploadId: created.uploadId,
+ formId,
+ storedPath: file.path,
+ mimeType: file.mimetype,
+ originalName: file.originalname,
+ });
+
+ return res.status(202).json({
+ uploadId: created.uploadId,
+ claimToken: created.claimToken,
+ status: created.status,
+ name: file.originalname,
+ mimeType: file.mimetype,
+ sizeBytes: file.size,
+ });
+ } catch (err) {
+ logger.error("[upload] failed to accept file", {
+ err: err instanceof Error ? err.message : err,
+ formId,
+ fieldId,
+ uploadId,
+ });
+
+ if (uploadId) {
+ await formUploadService
+ .markFailed({ uploadId, error: "Could not be queued for processing" })
+ .catch(() => {});
+ }
+
+ await discard(file.path);
+
+ return res.status(503).json({
+ error: "Could not accept the file right now. Please try again.",
+ });
+ }
+ },
+);
+
+uploadRouter.get("/uploads/limits", (_req: Request, res: Response) => {
+ res.setHeader("Cache-Control", "public, max-age=3600");
+ return res.json({
+ maxMb: env.UPLOAD_MAX_MB,
+ image: Math.min(env.UPLOAD_MAX_MB_IMAGE, env.UPLOAD_MAX_MB),
+ video: Math.min(env.UPLOAD_MAX_MB_VIDEO, env.UPLOAD_MAX_MB),
+ raw: Math.min(env.UPLOAD_MAX_MB_RAW, env.UPLOAD_MAX_MB),
+ });
+});
+
+uploadRouter.get("/uploads/:uploadId", async (req: Request, res: Response) => {
+ const { uploadId } = req.params as { uploadId: string };
+ const headerToken = req.header("x-upload-token");
+
+ if (!headerToken) {
+ return res.status(401).json({ error: "Missing upload token" });
+ }
+
+ try {
+ const status = await formUploadService.getUploadStatus({
+ uploadId,
+ claimToken: headerToken,
+ });
+
+ res.setHeader("Cache-Control", "no-store");
+ return res.json(status);
+ } catch {
+ return res.status(404).json({ error: "Upload not found" });
+ }
+});
+
+export function uploadErrorHandler(
+ err: unknown,
+ req: Request,
+ res: Response,
+ _next: NextFunction,
+): void {
+ if (!(err instanceof MulterError)) {
+ _next(err);
+ return;
+ }
+ void discard((req.file as { path?: string } | undefined)?.path);
+
+ if (err.code === "LIMIT_FILE_SIZE") {
+ res.status(413).json({
+ error: `That file is too large. The maximum size is ${env.UPLOAD_MAX_MB}MB.`,
+ });
+ return;
+ }
+
+ if (err.code === "LIMIT_UNEXPECTED_FILE") {
+ res.status(415).json({ error: err.field || "Unsupported file type" });
+ return;
+ }
+
+ res.status(400).json({ error: "Upload rejected" });
+}
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 52881be..58dd6d1 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -13,6 +13,8 @@ import { auth } from "@repo/trpc/server/auth";
import { toNodeHandler } from "better-auth/node";
import { env } from "./env";
+import { uploadRouter, uploadErrorHandler } from "./routes/upload";
+import { redisRateLimitStore } from "./lib/rate-limit-store";
export const app = express();
@@ -27,9 +29,6 @@ const openApiDocument = generateOpenApiDocument(serverRouter, {
baseUrl: env.BASE_URL.concat("/api"),
});
-// Origins that may hit the API with credentials. The first three are
-// kept for local dev / legacy previews. Production origins are appended
-// from `TRUSTED_ORIGINS` so we don't need a code change per environment.
const extraOrigins = (env.TRUSTED_ORIGINS ?? "")
.split(",")
.map((s) => s.trim())
@@ -42,12 +41,9 @@ const allowedOrigins = [
...extraOrigins,
];
-// CORS must run before every handler, including the better-auth handler.
-// Express v5 note: middleware registered with app.use() runs for ALL routes.
app.use(
cors({
origin: (origin, callback) => {
- // Allow requests with no origin (server-to-server, curl, etc.)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
@@ -55,21 +51,22 @@ app.use(
}
},
credentials: true,
- allowedHeaders: ["Content-Type", "Authorization", "Cookie", "Idempotency-Key"],
+ allowedHeaders: [
+ "Content-Type",
+ "Authorization",
+ "Cookie",
+ "Idempotency-Key",
+ "X-Upload-Token",
+ ],
exposedHeaders: ["Set-Cookie", "Server-Timing"],
- // Cache preflight for 2 hours — eliminates the ~700ms OPTIONS round-trip
- // on every cross-origin request after the first.
maxAge: 7200,
}),
);
-// gzip/brotli responses. Skip for already-compressed assets and small
-// payloads (< 1KB) where compression overhead outweighs the saving.
app.use(
compression({
threshold: 1024,
filter: (req, res) => {
- // Respect the standard opt-out header
if (req.headers["x-no-compression"]) return false;
return compression.filter(req, res);
},
@@ -79,48 +76,36 @@ app.use(
app.use(cookieParser());
const publicWriteLimiter = rateLimit({
- windowMs: 60_000, // 1 minute
- max: 60,
- standardHeaders: "draft-7", // RateLimit-* headers
+ windowMs: 60_000,
+ max: env.RATE_LIMIT_PUBLIC_WRITE_MAX,
+ standardHeaders: "draft-7",
legacyHeaders: false,
+ store: redisRateLimitStore("public-write"),
message: { error: "Too many requests — slow down and try again in a minute." },
});
const authGlobalLimiter = rateLimit({
windowMs: 60_000,
- max: 300, // generous for a logged-in tab making lots of small queries
+ max: env.RATE_LIMIT_AUTH_MAX,
standardHeaders: "draft-7",
legacyHeaders: false,
- // Key on session cookie if available, fall back to the proxy-aware IP
- // (ipKeyGenerator normalises IPv6 to a /64 prefix so we don't trivially
- // bucket every address separately).
keyGenerator: (req) =>
req.headers.cookie?.match(/better-auth\.session_token=([^;]+)/)?.[1] ??
ipKeyGenerator(req.ip ?? "unknown"),
+ store: redisRateLimitStore("auth-global"),
message: { error: "Request rate exceeded for this session." },
});
-// Apply the public write limiter to the form-side write endpoints. tRPC's
-// HTTP path includes the procedure name, so we can match on it.
app.use(
- [
- "/trpc/analytics.recordFieldAnswer",
- "/trpc/form.submitForm",
- // The report widget is reachable without an account, so it needs the same
- // IP ceiling as the other unauthenticated writes. The service adds a
- // per-account hourly cap on top, which covers the case one attacker
- // rotating IPs would otherwise slip through.
- "/trpc/feedback.submitFeedback",
- ],
+ ["/trpc/analytics.recordFieldAnswer", "/trpc/form.submitForm", "/trpc/feedback.submitFeedback"],
publicWriteLimiter,
);
-// Better Auth handler must be mounted BEFORE express.json().
-// Express v5 wildcard syntax: "*splat" (not "{*splat}" which is v4).
-// Per the better-auth Express docs, do NOT use express.json() before this.
app.all("/api/auth/*splat", toNodeHandler(auth));
-app.use(express.json({ limit: "200kb" })); // submission payload cap
+app.use(uploadRouter);
+
+app.use(express.json({ limit: "200kb" }));
app.get("/", (req, res) => {
return res.json({ message: "CanvasFlow is up and running..." });
@@ -130,7 +115,6 @@ app.get("/health", (req, res) => {
return res.json({ message: "CanvasFlow server is healthy", healthy: true });
});
-// Diagnostic: confirm which social providers are active (safe — no secrets)
app.get("/api/auth/providers", (req, res) => {
const providers = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) providers.push("google");
@@ -161,8 +145,6 @@ app.use(
}),
);
-// Global limiter sits in front of /trpc as a backstop. Per-procedure
-// limits above already handle the abusable public ones.
app.use("/trpc", authGlobalLimiter);
app.use(
"/trpc",
@@ -172,4 +154,24 @@ app.use(
}),
);
+app.use(uploadErrorHandler);
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+app.use(
+ (err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => {
+ logger.error("[api] unhandled error", {
+ method: req.method,
+ path: req.path,
+ err: err instanceof Error ? err.stack : err,
+ });
+
+ if (res.headersSent) {
+ res.end();
+ return;
+ }
+
+ res.status(500).json({ error: "Internal server error" });
+ },
+);
+
export default app;
diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json
index 6a73e78..956d8af 100644
--- a/apps/api/tsconfig.json
+++ b/apps/api/tsconfig.json
@@ -7,7 +7,6 @@
"moduleResolution": "Node",
"module": "CommonJS",
- // We don't need these for our final build
"declarationMap": false,
"declaration": false,
"sourceMap": false
diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx
index 35ef791..07ce74e 100644
--- a/apps/web/app/(auth)/layout.tsx
+++ b/apps/web/app/(auth)/layout.tsx
@@ -5,11 +5,7 @@ import { usePathname } from "next/navigation";
import React from "react";
import Noise from "~/components/Noise";
-import {
- HorizontalScale,
- HorizontalScaleDark,
- VerticalScaleDark,
-} from "~/components/Scale";
+import { HorizontalScale, HorizontalScaleDark, VerticalScaleDark } from "~/components/Scale";
const PANEL_POINTS: [string, string][] = [
["01", "Twelve field types, drag to reorder"],
diff --git a/apps/web/app/(auth)/signIn/page.tsx b/apps/web/app/(auth)/signIn/page.tsx
index ebe2ac4..9c60e2d 100644
--- a/apps/web/app/(auth)/signIn/page.tsx
+++ b/apps/web/app/(auth)/signIn/page.tsx
@@ -27,10 +27,17 @@ function SignInForm() {
const { signInUserWithEmailAndPassword, isPending } = useSignIn();
const [isSocialPending, setIsSocialPending] = React.useState(false);
- /* Where to land afterwards. Almost always the dashboard, but a respondent
- * sent here by a sign-in-gated form needs to come back to that form — landing
- * on the dashboard would leave them to find the link again themselves.
- * Sanitised, because an unchecked `redirect` is an open redirect. */
+ const switchAccount = searchParams.get("switch") === "1";
+
+ React.useEffect(() => {
+ if (switchAccount) {
+ authClient.signOut().then(() => {
+ document.cookie =
+ "cf_session=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; samesite=lax";
+ });
+ }
+ }, [switchAccount]);
+
const redirectTo = safeRedirect(searchParams.get("redirect"));
const {
@@ -45,8 +52,6 @@ function SignInForm() {
const handleSocialSignIn = async (provider: "google" | "github") => {
setIsSocialPending(true);
try {
- // The destination has to survive the round trip through the provider, so
- // it rides along on the callback URL rather than in local state.
const callback = new URL("/auth/callback", window.location.origin);
if (redirectTo !== "/dashboard") callback.searchParams.set("redirect", redirectTo);
@@ -60,9 +65,6 @@ function SignInForm() {
}
};
- // `useSignIn` reports outcome through callbacks and resolves either way, so
- // success has to be driven from `onSuccess`. Awaiting it and assuming
- // success is what previously let a rejected login navigate to the dashboard.
const onSubmit = (data: SignInValues) =>
signInUserWithEmailAndPassword(data, {
onSuccess: () => {
@@ -150,14 +152,11 @@ function SignInForm() {
Don’t have an account?
- {/* Carries the destination across, because a respondent gated out of a
- form often doesn't have an account yet — dropping it here would send
- them to the dashboard after signing up and lose the form. */}
@@ -184,10 +183,6 @@ function SignInForm() {
}
export default function SignInPage() {
- /* `useSearchParams` opts the page out of prerendering unless it sits under a
- * Suspense boundary — the build fails outright otherwise. Null fallback
- * because the shell around this comes from the (auth) layout, so a spinner
- * here would flash inside an already-rendered frame. */
return (
diff --git a/apps/web/app/(auth)/signUp/page.tsx b/apps/web/app/(auth)/signUp/page.tsx
index cc43fe1..12bac2c 100644
--- a/apps/web/app/(auth)/signUp/page.tsx
+++ b/apps/web/app/(auth)/signUp/page.tsx
@@ -28,6 +28,17 @@ function SignUpForm() {
const { createUserWithEmailAndPassword, isPending } = useSignUp();
const [isSocialPending, setIsSocialPending] = React.useState(false);
+ const switchAccount = searchParams.get("switch") === "1";
+
+ React.useEffect(() => {
+ if (switchAccount) {
+ authClient.signOut().then(() => {
+ document.cookie =
+ "cf_session=; path=/; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; secure; samesite=lax";
+ });
+ }
+ }, [switchAccount]);
+
/** Set when someone arrived from a sign-in-gated form and had no account. */
const redirectTo = safeRedirect(searchParams.get("redirect"));
@@ -157,8 +168,8 @@ function SignUpForm() {
@@ -185,7 +196,6 @@ function SignUpForm() {
}
export default function SignUpPage() {
- // Suspense boundary for `useSearchParams` — see the note on SignInPage.
return (
diff --git a/apps/web/app/dashboard/analytics/page.tsx b/apps/web/app/dashboard/analytics/page.tsx
index 8834cb0..75323d6 100644
--- a/apps/web/app/dashboard/analytics/page.tsx
+++ b/apps/web/app/dashboard/analytics/page.tsx
@@ -84,9 +84,6 @@ export function AnalyticsPage() {
const selectedFormId = searchParams.get("form");
- // Memoised so the auto-select effect below can depend on it honestly. As a
- // plain function it was reallocated every render, which is why that effect
- // needed an exhaustive-deps suppression to avoid firing on every pass.
const setSelectedFormId = useCallback(
(id: string) => {
router.replace(`/dashboard/analytics?form=${id}`, { scroll: false });
@@ -104,11 +101,8 @@ export function AnalyticsPage() {
isFetchingNextPage,
fetchNextPage,
} = useGetSubmissions(selectedFormId || "");
- // No longer conditional on a subscription tier — every signed-in owner gets
- // the detailed breakdown, so the query runs whenever a form is selected.
const { detailedAnalytics } = useGetDetailedAnalytics(selectedFormId || "");
- // Auto-select the first form when none is in the URL.
useEffect(() => {
if (forms && forms.length > 0 && !selectedFormId) {
const firstForm = forms[0];
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx
index ec97ab0..3cb6005 100644
--- a/apps/web/app/dashboard/layout.tsx
+++ b/apps/web/app/dashboard/layout.tsx
@@ -139,75 +139,78 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
-
New canvas
-
- New form
- .
-
-
- Give your form a title and a unique slug. You can rename it later — your data keys
- stay stable.
-
-
-
-
-
-
-
-
-
-
- /
-
+
New canvas
+
+ New form
+ .
+
+
+ Give your form a title and a unique slug. You can rename it later — your data keys
+ stay stable.
+
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+
+
+ setDescription(e.target.value)}
+ rows={3}
+ className="cf-dark-input resize-none px-4 py-3 text-[14px]"
/>
+
+
+
+
setCreateModalOpen(false)}
+ className="cf-dark-btn-outline px-4 py-2 text-[13px]"
+ >
+ Cancel
+
+
+ {isCreatingForm ? "Creating..." : "Create form"}
+
+
-
-
-
- setDescription(e.target.value)}
- rows={3}
- className="cf-dark-input resize-none px-4 py-3 text-[14px]"
- />
-
-
-
-
setCreateModalOpen(false)}
- className="cf-dark-btn-outline px-4 py-2 text-[13px]"
- >
- Cancel
-
-
- {isCreatingForm ? "Creating..." : "Create form"}
-
-
-
-
+
diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx
index 30b5b36..6e8ac6c 100644
--- a/apps/web/app/dashboard/page.tsx
+++ b/apps/web/app/dashboard/page.tsx
@@ -147,9 +147,7 @@ export default function DashboardPage() {
Response trends
-
- {activeRange.subtitle}
-
+
{activeRange.subtitle}
{/* Segmented control, squared and hairlined to match the chrome. */}
@@ -255,9 +253,7 @@ export default function DashboardPage() {
const count = Number(payload[0]?.value ?? 0);
return (
-
- {label}
-
+
{label}
{count} response
{count === 1 ? "" : "s"}
@@ -296,9 +292,7 @@ export default function DashboardPage() {
-
- {stats?.recentForms.length ?? 0} shown
-
+ {stats?.recentForms.length ?? 0} shown
-
- {item.submissionsCount}
-
+ {item.submissionsCount}
resp.
@@ -384,9 +376,7 @@ function SummaryMetric({ label, value, sub }: { label: string; value: string; su
{value}
- {sub && (
-
{sub}
- )}
+ {sub &&
{sub}
}
);
}
diff --git a/apps/web/app/dashboard/profile/page.tsx b/apps/web/app/dashboard/profile/page.tsx
index a324d01..1d1844d 100644
--- a/apps/web/app/dashboard/profile/page.tsx
+++ b/apps/web/app/dashboard/profile/page.tsx
@@ -380,11 +380,6 @@ export default function ProfilePage() {
>
Cancel
- {/* Red, not the accent blue. `--cf-danger` is the repo's named
- destructive token — the same one `cf-btn-danger` uses — so
- this can't drift from the other destructive buttons. The
- border matches the fill; `cf-btn` would otherwise outline it
- in near-black, which reads as a shadow on a dark panel. */}
(null);
-
- const { form, isLoading: formLoading, refetch: refetchForm } = useGetForm(formId);
- const { fields, isLoading: fieldsLoading, refetch: refetchFields } = useListFormFields(formId);
- const { segments, refetch: refetchSegments } = useListFormSegments(formId);
- const { logicRules, refetch: refetchRules } = useListLogicRules(formId);
-
- const { setIsCreatingForm } = useDashboard();
- useEffect(() => {
- if (!formLoading && !fieldsLoading) {
- setIsCreatingForm(false);
- }
- }, [formLoading, fieldsLoading, setIsCreatingForm]);
-
- const { createFormFieldAsync } = useCreateFormField();
- const { updateFormFieldAsync } = useUpdateFormField();
- const { deleteFormFieldAsync } = useDeleteFormField();
- const { createFormSegmentAsync } = useCreateFormSegment();
- const { updateFormSegmentAsync } = useUpdateFormSegment();
- const { deleteFormSegmentAsync } = useDeleteFormSegment();
- const { createLogicRuleAsync } = useCreateLogicRule();
- const { updateLogicRuleAsync } = useUpdateLogicRule();
- const { deleteLogicRuleAsync } = useDeleteLogicRule();
- const { publishForm, isPending: publishPending } = usePublishForm();
- const { deleteFormAsync, isPending: deletePending } = useDeleteForm();
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
- const [showShareDialog, setShowShareDialog] = useState(false);
- const [showSettingsDialog, setShowSettingsDialog] = useState(false);
-
- /* ─── Local draft state ────────────────────────────────────────────── */
- type LocalField = NonNullable[number] & { _isNew?: boolean };
- const [localFields, setLocalFields] = useState([]);
- const [dirtyIds, setDirtyIds] = useState>(new Set());
- const [pendingDeletes, setPendingDeletes] = useState>(new Set());
-
- /* Segments and branching rules share the field draft model: edited locally,
- * persisted together on Save. They can't be saved eagerly instead, because
- * the three are interdependent — a question needs its segment's real id, and
- * a rule needs its question's real id, and both are temporary until saved.
- * Keeping all three in one draft means `handleSave` can resolve those ids in
- * one pass rather than the editor hitting "you must save first". */
- type LocalSegment = NonNullable[number] & { _isNew?: boolean };
- const [localSegments, setLocalSegments] = useState([]);
- const [dirtySegmentIds, setDirtySegmentIds] = useState>(new Set());
- const [pendingSegmentDeletes, setPendingSegmentDeletes] = useState>(new Set());
-
- type LocalRule = NonNullable[number] & { _isNew?: boolean };
- const [localRules, setLocalRules] = useState([]);
- const [dirtyRuleIds, setDirtyRuleIds] = useState>(new Set());
- const [pendingRuleDeletes, setPendingRuleDeletes] = useState>(new Set());
-
- /** Which segment the canvas/outline is filtered to. null = show everything. */
- const [selectedSegmentId, setSelectedSegmentId] = useState(null);
-
- /** Whether the phone layout's segment list is expanded. Collapsed by default
- * so it doesn't push the question outline off the screen. */
- const [mobileSegmentsOpen, setMobileSegmentsOpen] = useState(false);
-
- const [isSaving, setIsSaving] = useState(false);
- const [justSaved, setJustSaved] = useState(false);
- const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
- const pendingNavRef = useRef(null);
- const isDirtyRef = useRef(false);
-
- const isDirty =
- dirtyIds.size > 0 ||
- pendingDeletes.size > 0 ||
- dirtySegmentIds.size > 0 ||
- pendingSegmentDeletes.size > 0 ||
- dirtyRuleIds.size > 0 ||
- pendingRuleDeletes.size > 0;
-
- useEffect(() => {
- isDirtyRef.current = isDirty;
- }, [isDirty]);
-
- useEffect(() => {
- if (fields && localFields.length === 0) {
- setLocalFields(fields as LocalField[]);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [fields]);
-
- // Seeded once, like localFields. `dirty` guards rather than `length === 0`
- // because an empty segment list is the normal state for most forms, and
- // re-seeding on every refetch would wipe an in-progress split.
- const segmentsSeededRef = useRef(false);
- useEffect(() => {
- if (segments && !segmentsSeededRef.current) {
- segmentsSeededRef.current = true;
- setLocalSegments(segments as LocalSegment[]);
- }
- }, [segments]);
-
- const rulesSeededRef = useRef(false);
- useEffect(() => {
- if (logicRules && !rulesSeededRef.current) {
- rulesSeededRef.current = true;
- setLocalRules(logicRules as LocalRule[]);
- }
- }, [logicRules]);
-
- useEffect(() => {
- const handler = (e: BeforeUnloadEvent) => {
- if (isDirtyRef.current) {
- e.preventDefault();
- e.returnValue = "";
- }
- };
- window.addEventListener("beforeunload", handler);
- return () => window.removeEventListener("beforeunload", handler);
- }, []);
-
- /* ─── Save all pending changes ─────────────────────────────────────── */
- const handleSave = useCallback(async () => {
- if (!isDirty || isSaving) return;
- setIsSaving(true);
- try {
- const tempIdToRealId = new Map();
- const updatedVersionById = new Map();
-
- /* ── Phase 1: segments ──────────────────────────────────────────────
- *
- * Ahead of fields because a question's `segmentId` has to point at a
- * row that exists. New segments are created one at a time rather than
- * through Promise.all: `createFormSegment` reads MAX(index) inside a
- * transaction, and parallel calls would either collide on
- * UNIQUE(form_id, index) or serialise on the row lock anyway.
- *
- * `adoptUnassignedFields: false` because the builder has already
- * assigned questions to segments in its own draft — letting the server
- * also create a first segment would duplicate it. */
- const tempSegmentIdToRealId = new Map();
- const updatedSegmentVersionById = new Map();
-
- const newSegments = localSegments
- .filter((s) => s._isNew && !pendingSegmentDeletes.has(s.id))
- .sort((a, b) => parseIndex(a.index) - parseIndex(b.index));
-
- for (const segment of newSegments) {
- const created = await createFormSegmentAsync({
- formId,
- title: segment.title.trim() || "Untitled segment",
- description: segment.description ?? undefined,
- index: parseIndex(segment.index),
- adoptUnassignedFields: false,
- });
- if (created?.id) tempSegmentIdToRealId.set(segment.id, created.id);
- }
-
- await Promise.all(
- localSegments
- .filter((s) => !s._isNew && dirtySegmentIds.has(s.id) && !pendingSegmentDeletes.has(s.id))
- .map((s) =>
- updateFormSegmentAsync({
- id: s.id,
- title: s.title.trim() || "Untitled segment",
- description: s.description ?? undefined,
- index: String(s.index),
- expectedVersion: typeof s.version === "number" ? s.version : 0,
- }).then((data) => {
- updatedSegmentVersionById.set(s.id, data.version);
- }),
- ),
- );
-
- /** Segment ids as the server knows them. */
- const resolveSegmentId = (id: string | null | undefined): string | null => {
- if (!id) return null;
- return tempSegmentIdToRealId.get(id) ?? id;
- };
-
- /* ── Phase 2: fields ─────────────────────────────────────────────── */
- const createOps = localFields
- .filter((f) => f._isNew && !pendingDeletes.has(f.id))
- .map((f) => {
- const tempId = f.id;
- return createFormFieldAsync({
- formId,
- segmentId: resolveSegmentId(f.segmentId),
- label: f.label,
- type: f.type as any,
- isRequired: f.isRequired,
- placeholder: f.placeholder ?? undefined,
- description: f.description ?? undefined,
- options: f.options ?? undefined,
- index: f.index ? parseFloat(String(f.index)) : undefined,
- }).then((data: any) => {
- if (data?.id) tempIdToRealId.set(tempId, data.id);
- });
- });
-
- const updateOps = localFields
- .filter((f) => !f._isNew && dirtyIds.has(f.id) && !pendingDeletes.has(f.id))
- .map((f) =>
- updateFormFieldAsync({
- id: f.id,
- segmentId: resolveSegmentId(f.segmentId),
- label: f.label,
- placeholder: f.placeholder ?? undefined,
- description: f.description ?? undefined,
- isRequired: f.isRequired,
- options: f.options ?? undefined,
- index: f.index ? String(f.index) : undefined,
- expectedVersion: typeof (f as any).version === "number" ? (f as any).version : 0,
- }).then((data) => {
- updatedVersionById.set(f.id, data.version);
- }),
- );
-
- const deleteOps = localFields
- .filter((f) => !f._isNew && pendingDeletes.has(f.id))
- .map((f) => deleteFormFieldAsync({ id: f.id }));
-
- await Promise.all([...createOps, ...updateOps, ...deleteOps]);
-
- /* ── Phase 3: branching rules and their conditions ───────────────────
- *
- * Last, because a rule references up to five other rows — the question
- * it triggers on, one per condition, and its two jump targets — any of
- * which may have only just been assigned a real id.
- *
- * Incomplete rules are skipped rather than sent. The server (and the
- * CHECK constraints behind it) would reject a jump with no destination,
- * and failing the whole save over a half-filled branch the author is
- * still working on would be worse than leaving it unsaved. The dialog
- * and the inspector both already flag those. */
- const resolveFieldId = (id: string | null | undefined): string | null => {
- if (!id) return null;
- return tempIdToRealId.get(id) ?? id;
- };
-
- /** A rule as the server needs to see it: every local/temporary id
- * swapped for the real one now that fields and segments exist. */
- const resolveRule = (rule: LocalRule) => ({
- ...rule,
- fieldId: resolveFieldId(rule.fieldId) ?? rule.fieldId,
- targetFieldId: rule.action === "JUMP_TO_FIELD" ? resolveFieldId(rule.targetFieldId) : null,
- targetSegmentId:
- rule.action === "JUMP_TO_SEGMENT" ? resolveSegmentId(rule.targetSegmentId) : null,
- elseTargetFieldId:
- rule.elseAction === "JUMP_TO_FIELD" ? resolveFieldId(rule.elseTargetFieldId) : null,
- elseTargetSegmentId:
- rule.elseAction === "JUMP_TO_SEGMENT" ? resolveSegmentId(rule.elseTargetSegmentId) : null,
- conditions: (rule.conditions ?? []).map((condition) => ({
- ...condition,
- fieldId: resolveFieldId(condition.fieldId) ?? condition.fieldId,
- })),
- });
-
- /** Saveable means: complete per the shared rule check, and every row it
- * points at still exists after this save's deletions. */
- const isSaveable = (rule: LocalRule): boolean => {
- const resolved = resolveRule(rule);
- if (!isRuleComplete(resolved as unknown as FlowRule)) return false;
- if (pendingDeletes.has(rule.fieldId)) return false;
-
- const referenced = [
- resolved.fieldId,
- resolved.targetFieldId,
- resolved.elseTargetFieldId,
- ...resolved.conditions.map((c) => c.fieldId),
- ].filter((id): id is string => !!id);
-
- return referenced.every((id) => !pendingDeletes.has(id) && !!resolveFieldId(id));
- };
-
- const conditionPayload = (rule: ReturnType) =>
- rule.conditions.map((condition, i) => ({
- fieldId: condition.fieldId,
- operator: condition.operator,
- value:
- condition.operator === "IS_EMPTY" || condition.operator === "IS_NOT_EMPTY"
- ? undefined
- : condition.value,
- index: i + 1,
- }));
-
- const skippedRules = localRules.filter(
- (r) =>
- !pendingRuleDeletes.has(r.id) && (r._isNew || dirtyRuleIds.has(r.id)) && !isSaveable(r),
- ).length;
-
- const ruleCreateOps = localRules
- .filter((r) => r._isNew && !pendingRuleDeletes.has(r.id) && isSaveable(r))
- .map((r) => {
- const resolved = resolveRule(r);
- return createLogicRuleAsync({
- formId,
- fieldId: resolved.fieldId,
- match: resolved.match,
- conditions: conditionPayload(resolved),
- action: resolved.action,
- targetFieldId: resolved.targetFieldId,
- targetSegmentId: resolved.targetSegmentId,
- elseAction: resolved.elseAction ?? null,
- elseTargetFieldId: resolved.elseTargetFieldId,
- elseTargetSegmentId: resolved.elseTargetSegmentId,
- index: parseIndex(r.index),
- });
- });
-
- const ruleUpdateOps = localRules
- .filter(
- (r) =>
- !r._isNew && dirtyRuleIds.has(r.id) && !pendingRuleDeletes.has(r.id) && isSaveable(r),
- )
- .map((r) => {
- const resolved = resolveRule(r);
- return updateLogicRuleAsync({
- id: r.id,
- match: resolved.match,
- conditions: conditionPayload(resolved),
- action: resolved.action,
- targetFieldId: resolved.targetFieldId,
- targetSegmentId: resolved.targetSegmentId,
- elseAction: resolved.elseAction ?? null,
- elseTargetFieldId: resolved.elseTargetFieldId,
- elseTargetSegmentId: resolved.elseTargetSegmentId,
- index: String(r.index),
- expectedVersion: typeof r.version === "number" ? r.version : 0,
- });
- });
-
- const ruleDeleteOps = localRules
- .filter((r) => !r._isNew && pendingRuleDeletes.has(r.id))
- .map((r) => deleteLogicRuleAsync({ id: r.id }));
-
- await Promise.all([...ruleCreateOps, ...ruleUpdateOps, ...ruleDeleteOps]);
-
- /* Segment deletions come after rule writes: deleting a segment cascades
- * away the rules that target it, so doing it earlier would race a rule
- * update against its own removal. */
- await Promise.all(
- localSegments
- .filter((s) => !s._isNew && pendingSegmentDeletes.has(s.id))
- .map((s) => deleteFormSegmentAsync({ id: s.id })),
- );
-
- // Rules and segments carry server-assigned ids and versions that the
- // local draft can't derive, so refetch rather than patch in place. The
- // field draft is still reconciled locally below to preserve canvas
- // node positions and selection.
- const [freshSegments, freshRules] = await Promise.all([refetchSegments(), refetchRules()]);
- if (freshSegments.data) setLocalSegments(freshSegments.data as LocalSegment[]);
- if (freshRules.data) setLocalRules(freshRules.data as LocalRule[]);
- setDirtySegmentIds(new Set());
- setPendingSegmentDeletes(new Set());
- setDirtyRuleIds(new Set());
- setPendingRuleDeletes(new Set());
- if (selectedSegmentId && tempSegmentIdToRealId.has(selectedSegmentId)) {
- setSelectedSegmentId(tempSegmentIdToRealId.get(selectedSegmentId) as string);
- }
- if (skippedRules > 0) {
- toast.warning(
- `${skippedRules} branching ${skippedRules === 1 ? "rule was" : "rules were"} left unsaved — finish setting ${skippedRules === 1 ? "it" : "them"} up`,
- );
- }
-
- setLocalFields((prev) =>
- prev
- .filter((f) => !pendingDeletes.has(f.id))
- .map((f) => {
- const realId = tempIdToRealId.get(f.id);
- const newVersion = updatedVersionById.get(realId ?? f.id);
- const next: any = { ...f, _isNew: false };
- if (realId) next.id = realId;
- if (newVersion !== undefined) next.version = newVersion;
- // A field created against a brand-new segment still holds the
- // temporary segment id locally; swap it for the real one so a
- // second save doesn't try to re-point it.
- next.segmentId = resolveSegmentId(f.segmentId);
- return next;
- }),
- );
- setSelectedNodeId((prev) =>
- prev && tempIdToRealId.has(prev) ? (tempIdToRealId.get(prev) as string) : prev,
- );
- setDirtyIds(new Set());
- setPendingDeletes(new Set());
- setJustSaved(true);
- window.setTimeout(() => setJustSaved(false), 1800);
- toast.success("Saved");
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err);
- const isLockConflict =
- message.includes("modified by someone else") ||
- message.includes("raced with another change");
-
- if (isLockConflict) {
- toast.error("This form was edited from another session — reloading your view");
- // All three have to come back together. Reloading only fields would
- // leave segment assignments and branch rules pointing at ids the
- // refreshed field list may no longer contain.
- const [refreshed, freshSegments, freshRules] = await Promise.all([
- refetchFields(),
- refetchSegments(),
- refetchRules(),
- ]);
- if (refreshed.data) {
- setLocalFields(refreshed.data as any);
- setDirtyIds(new Set());
- setPendingDeletes(new Set());
- }
- if (freshSegments.data) {
- setLocalSegments(freshSegments.data as LocalSegment[]);
- setDirtySegmentIds(new Set());
- setPendingSegmentDeletes(new Set());
- }
- if (freshRules.data) {
- setLocalRules(freshRules.data as LocalRule[]);
- setDirtyRuleIds(new Set());
- setPendingRuleDeletes(new Set());
- }
- } else {
- toast.error("Some changes failed to save — please retry");
- }
- } finally {
- setIsSaving(false);
- }
- }, [
- isDirty,
- isSaving,
- localFields,
- dirtyIds,
- pendingDeletes,
+ const {
formId,
- createFormFieldAsync,
- updateFormFieldAsync,
- deleteFormFieldAsync,
- refetchFields,
- localSegments,
- dirtySegmentIds,
- pendingSegmentDeletes,
- localRules,
- dirtyRuleIds,
- pendingRuleDeletes,
+ reactFlowWrapper,
+ form,
+ formLoading,
+ refetchForm,
+ fieldsLoading,
+ publishPending,
+ deletePending,
+ showDeleteConfirm,
+ setShowDeleteConfirm,
+ showShareDialog,
+ setShowShareDialog,
+ showSettingsDialog,
+ setShowSettingsDialog,
+ setDirtyIds,
+ setPendingDeletes,
selectedSegmentId,
- createFormSegmentAsync,
- updateFormSegmentAsync,
- deleteFormSegmentAsync,
- createLogicRuleAsync,
- updateLogicRuleAsync,
- deleteLogicRuleAsync,
- refetchSegments,
- refetchRules,
- ]);
-
- const updateLocal = useCallback((id: string, patch: Partial) => {
- setLocalFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
- setDirtyIds((prev) => new Set(prev).add(id));
- }, []);
-
- const localFieldsRef = useRef(localFields);
- useEffect(() => {
- localFieldsRef.current = localFields;
- }, [localFields]);
-
- const maxLocalIndex = useCallback(
- () =>
- localFieldsRef.current.reduce((m, f) => {
- const v = parseIndex(f.index);
- return Number.isFinite(v) && v > m ? v : m;
- }, 0),
- [],
- );
-
- const updateManyLocal = useCallback((patches: Map>) => {
- if (patches.size === 0) return;
- setLocalFields((prev) =>
- prev.map((f) => {
- const patch = patches.get(f.id);
- return patch ? { ...f, ...patch } : f;
- }),
- );
- setDirtyIds((prev) => {
- const next = new Set(prev);
- for (const id of patches.keys()) next.add(id);
- return next;
- });
- }, []);
-
- /* ─── Segments ─────────────────────────────────────────────────────── */
-
- const visibleSegments = useMemo(
- () =>
- localSegments
- .filter((s) => !pendingSegmentDeletes.has(s.id))
- .sort((a, b) => parseIndex(a.index) - parseIndex(b.index)),
- [localSegments, pendingSegmentDeletes],
- );
-
- const updateSegmentLocal = useCallback((id: string, patch: Partial) => {
- setLocalSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
- setDirtySegmentIds((prev) => new Set(prev).add(id));
- }, []);
-
- /**
- * Add a segment.
- *
- * The first one is the interesting case. A form with no segments is one
- * continuous list, so "split into segments" has to put the questions that
- * already exist somewhere — otherwise they'd sit outside every segment and
- * the editor would be looking at a form whose contents aren't on any page.
- * So the first click creates "Segment 1" and moves everything into it; the
- * next click adds an empty "Segment 2" to branch into.
- */
- const handleAddSegment = useCallback(() => {
- const isFirst = visibleSegments.length === 0;
- const tempId = `new-seg-${Date.now()}`;
- const nextIndex = isFirst
- ? 1
- : Math.max(...visibleSegments.map((s) => parseIndex(s.index) || 0)) + 1;
-
- const created = {
- id: tempId,
- formId,
- title: isFirst ? "Segment 1" : `Segment ${visibleSegments.length + 1}`,
- description: null,
- index: formatIndex(nextIndex),
- version: 0,
- createdAt: new Date(),
- updatedAt: new Date(),
- _isNew: true,
- } as unknown as LocalSegment;
-
- setLocalSegments((prev) => [...prev, created]);
- setDirtySegmentIds((prev) => new Set(prev).add(tempId));
-
- if (isFirst) {
- const patches = new Map>();
- for (const field of localFieldsRef.current) {
- if (pendingDeletes.has(field.id)) continue;
- patches.set(field.id, { segmentId: tempId });
- }
- updateManyLocal(patches);
- toast.success("Existing questions moved into Segment 1 — add another to branch between them");
- }
-
- setSelectedSegmentId(tempId);
- }, [visibleSegments, formId, pendingDeletes, updateManyLocal]);
-
- const handleMoveSegment = useCallback(
- (id: string, direction: "up" | "down") => {
- const at = visibleSegments.findIndex((s) => s.id === id);
- if (at === -1) return;
- const swapWith = direction === "up" ? at - 1 : at + 1;
- if (swapWith < 0 || swapWith >= visibleSegments.length) return;
-
- // Swapping the two index values is a two-row write and can't collide,
- // whereas computing a value between the neighbour and its neighbour
- // would need a third read. Segment lists are short; this is enough.
- const a = visibleSegments[at]!;
- const b = visibleSegments[swapWith]!;
- updateSegmentLocal(a.id, { index: String(b.index) });
- updateSegmentLocal(b.id, { index: String(a.index) });
- },
- [visibleSegments, updateSegmentLocal],
- );
-
- /**
- * Remove a segment from the draft.
- *
- * Its questions are re-pointed at the previous segment here, mirroring what
- * the service does on the server, so the canvas doesn't briefly show them
- * flung to the front of the form before the save lands.
- */
- const handleDeleteSegment = useCallback(
- (id: string) => {
- const at = visibleSegments.findIndex((s) => s.id === id);
- const adoptive = at > 0 ? visibleSegments[at - 1] : visibleSegments[at + 1];
- const adoptiveId = adoptive?.id ?? null;
-
- const patches = new Map>();
- for (const field of localFieldsRef.current) {
- if (field.segmentId === id) {
- patches.set(field.id, { segmentId: adoptiveId });
- }
- }
- if (patches.size > 0) updateManyLocal(patches);
-
- // Rules pointing at this segment would become jumps to nowhere.
- setLocalRules((prev) => prev.filter((r) => r.targetSegmentId !== id || r._isNew === true));
- setPendingRuleDeletes((prev) => {
- const next = new Set(prev);
- for (const rule of localRules) {
- if (rule.targetSegmentId === id && !rule._isNew) next.add(rule.id);
- }
- return next;
- });
-
- setPendingSegmentDeletes((prev) => new Set(prev).add(id));
- setDirtySegmentIds((prev) => new Set(prev).add(id));
- setSelectedSegmentId((prev) => (prev === id ? null : prev));
- toast("Segment removed — save to confirm", { duration: 2000 });
- },
- [visibleSegments, localRules, updateManyLocal],
- );
-
- /* ─── React Flow state ─────────────────────────────────────────────── */
- const [nodes, setNodes, onNodesChange] = useNodesState([]);
- const [edges, setEdges, onEdgesChange] = useEdgesState([]);
- const [selectedNodeId, setSelectedNodeId] = useState(null);
- const [isLocked, setIsLocked] = useState(false);
-
- /* ─── Branching ────────────────────────────────────────────────────────
- *
- * Rules trigger off the selected question, so these sit below the React Flow
- * state that owns the selection. Conditions are edited in place on their
- * rule rather than as their own draft collection: they only exist inside a
- * rule, are always saved with it, and giving them a separate dirty set would
- * mean tracking two things that can never disagree. */
-
- /** Which question's branching the dialog is showing, if any. */
- const [branchingFieldId, setBranchingFieldId] = useState(null);
-
- const rulesForField = useCallback(
- (fieldId: string | null) => {
- if (!fieldId) return [] as FlowRule[];
- return localRules
- .filter((r) => r.fieldId === fieldId && !pendingRuleDeletes.has(r.id))
- .sort((a, b) => parseIndex(a.index) - parseIndex(b.index)) as unknown as FlowRule[];
- },
- [localRules, pendingRuleDeletes],
- );
-
- const markRuleDirty = useCallback((id: string) => {
- setDirtyRuleIds((prev) => new Set(prev).add(id));
- }, []);
-
- const handleAddRule = useCallback(() => {
- const fieldId = branchingFieldId;
- if (!fieldId) return;
-
- const tempId = `new-rule-${Date.now()}`;
- const siblings = localRules.filter((r) => r.fieldId === fieldId);
- const nextIndex =
- siblings.length === 0 ? 1 : Math.max(...siblings.map((r) => parseIndex(r.index) || 0)) + 1;
-
- /* Opens with one condition already reading this question's own answer,
- * because that's the branch nine times out of ten and an empty rule gives
- * the author nothing to react to. Targets start unset and the rule reads
- * as incomplete until they're chosen. */
- const created = {
- id: tempId,
- formId,
- fieldId,
- match: "ALL",
- conditions: [
- {
- id: `new-cond-${Date.now()}`,
- ruleId: tempId,
- fieldId,
- operator: "EQUALS",
- value: "",
- index: "1",
- },
- ],
- action: "JUMP_TO_FIELD",
- targetFieldId: null,
- targetSegmentId: null,
- elseAction: null,
- elseTargetFieldId: null,
- elseTargetSegmentId: null,
- index: formatIndex(nextIndex),
- version: 0,
- createdAt: new Date(),
- updatedAt: new Date(),
- _isNew: true,
- } as unknown as LocalRule;
-
- setLocalRules((prev) => [...prev, created]);
- markRuleDirty(tempId);
- }, [branchingFieldId, localRules, formId, markRuleDirty]);
-
- const handleUpdateRule = useCallback(
- (id: string, patch: Partial) => {
- setLocalRules(
- (prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)) as unknown as LocalRule[],
- );
- markRuleDirty(id);
- },
- [markRuleDirty],
- );
-
- const handleDeleteRule = useCallback(
- (id: string) => {
- // A rule that was never saved can just disappear; there's nothing on the
- // server to tell about it, and keeping it in a pending-delete set would
- // have `handleSave` try to delete an id the server has never seen.
- const rule = localRules.find((r) => r.id === id);
- if (rule?._isNew) {
- setLocalRules((prev) => prev.filter((r) => r.id !== id));
- setDirtyRuleIds((prev) => {
- const next = new Set(prev);
- next.delete(id);
- return next;
- });
- return;
- }
- setPendingRuleDeletes((prev) => new Set(prev).add(id));
- markRuleDirty(id);
- },
- [localRules, markRuleDirty],
- );
-
- const handleDuplicateRule = useCallback(
- (id: string) => {
- const source = localRules.find((r) => r.id === id);
- if (!source) return;
-
- const tempId = `new-rule-${Date.now()}`;
- const siblings = localRules.filter((r) => r.fieldId === source.fieldId);
- const nextIndex = Math.max(...siblings.map((r) => parseIndex(r.index) || 0)) + 1;
-
- const copy = {
- ...source,
- id: tempId,
- index: formatIndex(nextIndex),
- version: 0,
- _isNew: true,
- // Fresh condition ids: the originals belong to rows on the server, and
- // reusing them would make the copy look like an edit of the original.
- conditions: (source.conditions ?? []).map((condition: FlowCondition, i: number) => ({
- ...condition,
- id: `new-cond-${Date.now()}-${i}`,
- ruleId: tempId,
- })),
- } as unknown as LocalRule;
-
- setLocalRules((prev) => [...prev, copy]);
- markRuleDirty(tempId);
- },
- [localRules, markRuleDirty],
- );
-
- const handleMoveRule = useCallback(
- (id: string, direction: "up" | "down") => {
- const source = localRules.find((r) => r.id === id);
- if (!source) return;
-
- const siblings = rulesForField(source.fieldId);
- const at = siblings.findIndex((r) => r.id === id);
- const swapWith = direction === "up" ? at - 1 : at + 1;
- if (at === -1 || swapWith < 0 || swapWith >= siblings.length) return;
-
- // Swapping the two index values is a two-row write that can't collide.
- // Rule order decides which branch is checked first, so it has to be
- // adjustable, and these lists are short.
- const a = siblings[at]!;
- const b = siblings[swapWith]!;
- handleUpdateRule(a.id, { index: String(b.index) });
- handleUpdateRule(b.id, { index: String(a.index) });
- },
- [localRules, rulesForField, handleUpdateRule],
- );
-
- const handleAddCondition = useCallback(
- (ruleId: string) => {
- setLocalRules((prev) =>
- prev.map((r) => {
- if (r.id !== ruleId) return r;
- const conditions = (r.conditions ?? []) as FlowCondition[];
- const nextIndex =
- conditions.length === 0
- ? 1
- : Math.max(...conditions.map((c) => parseIndex(c.index) || 0)) + 1;
- return {
- ...r,
- conditions: [
- ...conditions,
- {
- id: `new-cond-${Date.now()}`,
- ruleId,
- // Defaults to the trigger question, the most likely thing the
- // author wants to test next.
- fieldId: r.fieldId,
- operator: "EQUALS",
- value: "",
- index: String(nextIndex),
- },
- ],
- } as unknown as LocalRule;
- }),
- );
- markRuleDirty(ruleId);
- },
- [markRuleDirty],
- );
-
- const handleUpdateCondition = useCallback(
- (ruleId: string, conditionId: string, patch: Partial) => {
- setLocalRules((prev) =>
- prev.map((r) =>
- r.id === ruleId
- ? ({
- ...r,
- conditions: ((r.conditions ?? []) as FlowCondition[]).map((c) =>
- c.id === conditionId ? { ...c, ...patch } : c,
- ),
- } as unknown as LocalRule)
- : r,
- ),
- );
- markRuleDirty(ruleId);
- },
- [markRuleDirty],
- );
-
- const handleDeleteCondition = useCallback(
- (ruleId: string, conditionId: string) => {
- setLocalRules((prev) =>
- prev.map((r) =>
- r.id === ruleId
- ? ({
- ...r,
- conditions: ((r.conditions ?? []) as FlowCondition[]).filter(
- (c) => c.id !== conditionId,
- ),
- } as unknown as LocalRule)
- : r,
- ),
- );
- markRuleDirty(ruleId);
- },
- [markRuleDirty],
- );
-
- const [view, setView] = useState("canvas");
-
- useEffect(() => {
- try {
- const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
- if (saved === "canvas" || saved === "outline") setView(saved);
- else if (saved === "list") setView("outline");
- } catch {
- /* Private mode / storage disabled — the default is fine. */
- }
- }, []);
-
- const handleViewChange = useCallback((next: BuilderView) => {
- setView(next);
-
- try {
- window.localStorage.setItem(VIEW_STORAGE_KEY, next);
- } catch {
- /* Non-fatal: the switch still applies for this session. */
- }
- }, []);
-
- // Inspector controlled inputs
- const [label, setLabel] = useState("");
- const [placeholder, setPlaceholder] = useState("");
- const [isRequired, setIsRequired] = useState(false);
- const [description, setDescription] = useState("");
- const [optionsList, setOptionsList] = useState([]);
-
- const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow();
-
- useEffect(() => {
- const visible = localFields
- .filter((f) => !pendingDeletes.has(f.id))
- // Filtered to one segment when the editor picks one. A segmented form
- // is several short pages, and showing every page's questions on one
- // canvas is what made segments hard to read in the first place.
- .filter((f) => selectedSegmentId === null || f.segmentId === selectedSegmentId)
- .sort((a, b) => {
- const d = parseIndex(a.index) - parseIndex(b.index);
- return d !== 0 ? d : a.id.localeCompare(b.id);
- });
- setNodes((prevNodes) => {
- const prevById = new Map(prevNodes.map((n) => [n.id, n]));
- return visible.map((field, idx) => {
- const existing = prevById.get(field.id);
- if (existing) {
- return { ...existing, data: { field } };
- }
- const p = (typeof field.options === "object" && field.options ? (field.options as any) : {})
- .position || { x: 300, y: idx * 200 + 80 };
- return {
- id: field.id,
- type: "formField",
- position: p,
- data: { field },
- };
- });
- });
- const mappedEdges: Edge[] = [];
- const onCanvas = new Set(visible.map((f) => f.id));
-
- // The default fall-through order: dashed and faint, because it's what
- // happens when no rule matches rather than something the editor drew.
- for (let i = 0; i < visible.length - 1; i++) {
- const s = visible[i];
- const t = visible[i + 1];
- if (s && t)
- mappedEdges.push({
- id: `e-${s.id}-${t.id}`,
- source: s.id,
- target: t.id,
- animated: true,
- style: {
- stroke: "#f66f00",
- strokeWidth: 1.5,
- strokeOpacity: 0.55,
- strokeDasharray: "4,4",
- },
- });
- }
-
- /* Branch edges. Drawn solid and labelled with their condition, so the
- * canvas shows the routes a respondent can actually take instead of a
- * straight line that the rules contradict. Only edges whose endpoints are
- * both on the canvas are drawn — a jump into a different segment has no
- * visible target while the view is filtered, and React Flow would drop a
- * dangling edge anyway. */
- const targetOf = (
- action: string | null | undefined,
- fieldTarget: string | null | undefined,
- segmentTarget: string | null | undefined,
- ): string | null => {
- if (action === "JUMP_TO_FIELD") return fieldTarget ?? null;
- if (action === "JUMP_TO_SEGMENT" && segmentTarget) {
- return visible.find((f) => f.segmentId === segmentTarget)?.id ?? null;
- }
- return null;
- };
-
- for (const rule of localRules) {
- if (pendingRuleDeletes.has(rule.id)) continue;
- if (!onCanvas.has(rule.fieldId)) continue;
-
- const conditionCount = rule.conditions?.length ?? 0;
- // The label has to fit on an edge, so it counts rather than recites.
- // The full sentence lives in the inspector and the dialog.
- const label =
- conditionCount === 0
- ? "incomplete"
- : conditionCount === 1
- ? "if"
- : `if ${rule.match === "ANY" ? "any" : "all"} ${conditionCount}`;
-
- // The two sides get their own edge, so a fork reads as a fork.
- const sides: Array<{ suffix: string; target: string | null; label: string; solid: boolean }> =
- [
- {
- suffix: "then",
- target: targetOf(rule.action, rule.targetFieldId, rule.targetSegmentId),
- label,
- solid: true,
- },
- {
- suffix: "else",
- target: targetOf(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId),
- label: "otherwise",
- solid: false,
- },
- ];
-
- for (const side of sides) {
- if (!side.target || !onCanvas.has(side.target) || side.target === rule.fieldId) continue;
- mappedEdges.push({
- id: `branch-${rule.id}-${side.suffix}`,
- source: rule.fieldId,
- target: side.target,
- label: side.label,
- labelStyle: { fontSize: 10, fontFamily: "monospace" },
- labelBgStyle: { fill: "var(--cf-cream)" },
- style: side.solid
- ? { stroke: "#1a1d29", strokeWidth: 2 }
- : { stroke: "#1a1d29", strokeWidth: 1.5, strokeDasharray: "6,3", strokeOpacity: 0.7 },
- });
- }
- }
-
- setEdges(mappedEdges);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [localFields, pendingDeletes, selectedSegmentId, localRules, pendingRuleDeletes]);
-
- /* Counts for the segment panel, and the two option lists the branching
- * editor needs. Derived from the draft so a question moved between
- * segments is reflected before the save lands. */
- const segmentQuestionCounts = useMemo(() => {
- const counts: Record = {};
- for (const field of localFields) {
- if (pendingDeletes.has(field.id) || !field.segmentId) continue;
- counts[field.segmentId] = (counts[field.segmentId] ?? 0) + 1;
- }
- return counts;
- }, [localFields, pendingDeletes]);
-
- const unassignedQuestionCount = useMemo(
- () => localFields.filter((f) => !pendingDeletes.has(f.id) && !f.segmentId).length,
- [localFields, pendingDeletes],
- );
-
- const branchTargetSegments = useMemo(
- () =>
- visibleSegments.map((s, i) => ({
- id: s.id,
- label: `${i + 1}. ${s.title?.trim() || `Segment ${i + 1}`}`,
- })),
- [visibleSegments],
- );
-
- /** Branching rules still standing in the draft. Drives the layout advice in
- * settings, where a branching form can't be shown on a single page. */
- const activeRuleCount = useMemo(
- () => localRules.filter((r) => !pendingRuleDeletes.has(r.id)).length,
- [localRules, pendingRuleDeletes],
- );
-
- /**
- * The draft as a Flow — the same structure the public renderer walks.
- *
- * Built from local state, not the server's, so the dialog's pickers, its
- * plain-English summaries and its warnings all describe the form as it
- * stands in the editor rather than as it was last saved. It also means the
- * builder and the respondent are reading one implementation of the routing
- * rules, so a branch can't preview one way and behave another.
- *
- * Never filtered by the selected segment: the point of a branch is to leave
- * the segment you're in, so every question has to be offerable as a target.
- */
- const draftFlow = useMemo(
- () =>
- buildFlow(
- localFields.filter((f) => !pendingDeletes.has(f.id)) as unknown as FlowField[],
- visibleSegments as unknown as FlowSegment[],
- localRules.filter((r) => !pendingRuleDeletes.has(r.id)) as unknown as FlowRule[],
- ),
- [localFields, pendingDeletes, visibleSegments, localRules, pendingRuleDeletes],
- );
-
- /** Numbered names, shared by the dialog, the summaries and the warnings so
- * a question is called the same thing everywhere. */
- const flowLabels: FlowLabels = useMemo(() => {
- const positionOf = new Map(draftFlow.order.map((f, i) => [f.id, i + 1]));
- return {
- fieldLabel: (fieldId) => {
- const field = draftFlow.fieldById.get(fieldId);
- if (!field) return "a deleted question";
- const name =
- field.label?.trim() || `Untitled ${field.type.replace("_", " ").toLowerCase()}`;
- return `Q${positionOf.get(fieldId) ?? "?"} ${name}`;
- },
- segmentLabel: (segmentId) => {
- const at = draftFlow.segments.findIndex((s) => s.id === segmentId);
- if (at === -1) return "a deleted segment";
- return draftFlow.segments[at]?.title?.trim() || `Segment ${at + 1}`;
- },
- };
- }, [draftFlow]);
-
- /* Inspector summary for the selected question: one sentence per branch, plus
- * a count of the ones that aren't finished. */
- const selectedFieldRuleSummaries = useMemo(
- () => rulesForField(selectedNodeId).map((rule) => describeRule(rule, flowLabels)),
- [rulesForField, selectedNodeId, flowLabels],
- );
-
- const selectedFieldIncompleteRules = useMemo(
- () => rulesForField(selectedNodeId).filter((rule) => !isRuleComplete(rule)).length,
- [rulesForField, selectedNodeId],
- );
-
- /** Whether the selected question closes out its segment — the position
- * where a segment-level branch belongs, so the editor can say so. */
- const isSelectedFieldLastInSegment = useMemo(() => {
- if (!selectedNodeId) return false;
- const selected = localFields.find((f) => f.id === selectedNodeId);
- if (!selected?.segmentId) return false;
- const siblings = localFields
- .filter((f) => !pendingDeletes.has(f.id) && f.segmentId === selected.segmentId)
- .sort((a, b) => parseIndex(a.index) - parseIndex(b.index));
- return siblings[siblings.length - 1]?.id === selectedNodeId;
- }, [localFields, pendingDeletes, selectedNodeId]);
-
- const selectedField = useMemo(
- () => localFields.find((f) => f.id === selectedNodeId) ?? null,
- [localFields, selectedNodeId],
- );
-
- useEffect(() => {
- if (selectedField) {
- setLabel(selectedField.label);
- setPlaceholder(selectedField.placeholder || "");
- setIsRequired(selectedField.isRequired);
- setDescription(selectedField.description || "");
- setOptionsList(getFieldOptionsArray(selectedField));
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedField?.id]);
-
- /**
- * Which segment a newly added question belongs to.
- *
- * Shared by every add path, because they had drifted: dropping onto the
- * canvas assigned the selected segment while adding from the outline left
- * `segmentId` undefined. Since both the canvas and the outline are filtered
- * by the selected segment, and adding a segment selects it, a question added
- * from the outline right after creating a segment was filtered straight out
- * of the list it was added to — it existed in the draft and was invisible,
- * which reads as the button doing nothing.
- *
- * The selected segment wins when the view is filtered to one. Otherwise the
- * last segment, so a question added to an unfiltered segmented form lands at
- * the end rather than outside every page.
- */
- const nextSegmentId = useCallback(
- () => selectedSegmentId ?? visibleSegments[visibleSegments.length - 1]?.id ?? null,
- [selectedSegmentId, visibleSegments],
- );
-
- /* ─── Drag & Drop ──────────────────────────────────────────────────── */
- const onDragStart = (event: React.DragEvent, type: string) => {
- event.dataTransfer.setData("application/reactflow", type);
- event.dataTransfer.effectAllowed = "move";
- };
-
- const onDragOver = useCallback((event: React.DragEvent) => {
- event.preventDefault();
- event.dataTransfer.dropEffect = "move";
- }, []);
-
- const onDrop = useCallback(
- (event: React.DragEvent) => {
- event.preventDefault();
- const type = event.dataTransfer.getData("application/reactflow");
- if (!type || !reactFlowWrapper.current) return;
- const position = screenToFlowPosition({
- x: event.clientX,
- y: event.clientY,
- });
- const tempId = `new-${Date.now()}`;
- const nextIndex = formatIndex(maxLocalIndex() + 1);
- const newField = {
- id: tempId,
- formId,
- segmentId: nextSegmentId(),
- label: "",
- labelKey: "field",
- placeholder: null,
- isRequired: false,
- index: nextIndex,
- type: type as any,
- options: { position },
- description: null,
- version: 0,
- createdAt: new Date(),
- updatedAt: new Date(),
- _isNew: true,
- };
- setLocalFields((prev) => [...prev, newField]);
- setDirtyIds((prev) => new Set(prev).add(tempId));
- setSelectedNodeId(tempId);
- },
- [screenToFlowPosition, formId, maxLocalIndex, nextSegmentId],
- );
-
- const onNodeClick = useCallback((_: React.MouseEvent, node: any) => {
- setSelectedNodeId(node.id);
- }, []);
-
- const onPaneClick = useCallback(() => setSelectedNodeId(null), []);
-
- const onNodeDragStop = useCallback(
- (_event: any, node: Node) => {
- const currentField = localFields.find((f) => f.id === node.id);
- if (!currentField) return;
-
- const currentOpts =
- typeof currentField.options === "object" && currentField.options
- ? (currentField.options as Record)
- : {};
- const positionPatch = { options: { ...currentOpts, position: node.position } };
-
- const visible = localFields.filter((f) => !pendingDeletes.has(f.id));
-
- const livePos = new Map(nodes.map((n) => [n.id, n.position]));
- livePos.set(node.id, node.position);
- const posOf = (f: LocalField) =>
- livePos.get(f.id) ??
- ((f.options as any)?.position as { x: number; y: number } | undefined) ?? { x: 0, y: 0 };
-
- const ordered = [...visible].sort((a, b) => {
- const pa = posOf(a);
- const pb = posOf(b);
- if (pa.y !== pb.y) return pa.y - pb.y;
- if (pa.x !== pb.x) return pa.x - pb.x;
- return parseIndex(a.index) - parseIndex(b.index);
- });
-
- const at = ordered.findIndex((f) => f.id === node.id);
- if (at === -1) {
- updateLocal(node.id, positionPatch);
- return;
- }
-
- const before = at > 0 ? parseIndex(ordered[at - 1]!.index) : null;
- const after = at < ordered.length - 1 ? parseIndex(ordered[at + 1]!.index) : null;
- const current = parseIndex(currentField.index);
-
- if (isBetween(current, before, after)) {
- updateLocal(node.id, positionPatch);
- return;
- }
-
- const next = indexBetween(before, after);
- if (next !== null) {
- updateLocal(node.id, { ...positionPatch, index: next });
- return;
- }
-
- const base = maxLocalIndex() + 1;
- const patches = new Map>();
- ordered.forEach((f, i) => {
- patches.set(f.id, { index: formatIndex(base + i) });
- });
- patches.set(node.id, { ...patches.get(node.id), ...positionPatch });
- updateManyLocal(patches);
- },
- [localFields, pendingDeletes, nodes, updateLocal, updateManyLocal, maxLocalIndex],
- );
-
- const handleRequiredChange = useCallback(
- (checked: boolean) => {
- if (!selectedField) return;
- setIsRequired(checked);
- updateLocal(selectedField.id, { isRequired: checked });
- },
- [selectedField, updateLocal],
- );
-
- const handleDeleteField = useCallback(() => {
- if (!selectedNodeId) return;
- setPendingDeletes((prev) => new Set(prev).add(selectedNodeId));
- setDirtyIds((prev) => new Set(prev).add(selectedNodeId));
- setSelectedNodeId(null);
- toast("Field removed — save to confirm", { duration: 2000 });
- }, [selectedNodeId]);
-
- const [mobileAddOpen, setMobileAddOpen] = useState(false);
- const [mobileEditorOpen, setMobileEditorOpen] = useState(false);
-
- const visibleSortedFields = useMemo(
- () =>
- localFields
- .filter((f) => !pendingDeletes.has(f.id))
- // Same segment filter the canvas applies, so the outline and the
- // canvas always agree about what's on screen.
- .filter((f) => selectedSegmentId === null || f.segmentId === selectedSegmentId)
- .sort((a, b) => {
- const d = parseIndex(a.index) - parseIndex(b.index);
- return d !== 0 ? d : a.id.localeCompare(b.id);
- }),
- [localFields, pendingDeletes, selectedSegmentId],
- );
-
- const handleMobileTapField = useCallback((id: string) => {
- setSelectedNodeId(id);
- setMobileEditorOpen(true);
- }, []);
-
- const handleCloseMobileEditor = useCallback(() => {
- setMobileEditorOpen(false);
- setSelectedNodeId(null);
- }, []);
-
- const handleMobileMove = useCallback(
- (id: string, direction: "up" | "down") => {
- const list = visibleSortedFields;
- const i = list.findIndex((f) => f.id === id);
- if (i === -1) return;
- const j = direction === "up" ? i - 1 : i + 1;
- if (j < 0 || j >= list.length) return;
-
- const beforeField = direction === "up" ? list[j - 1] : list[j];
- const afterField = direction === "up" ? list[j] : list[j + 1];
-
- const next = indexBetween(
- beforeField ? parseIndex(beforeField.index) : null,
- afterField ? parseIndex(afterField.index) : null,
- );
-
- if (next !== null) {
- updateLocal(id, { index: next });
- return;
- }
-
- const reordered = [...list];
- const [moved] = reordered.splice(i, 1);
- reordered.splice(j, 0, moved!);
-
- const base = maxLocalIndex() + 1;
- const patches = new Map>();
- reordered.forEach((f, k) => {
- patches.set(f.id, { index: formatIndex(base + k) });
- });
- updateManyLocal(patches);
- },
- [visibleSortedFields, updateLocal, updateManyLocal, maxLocalIndex],
- );
-
- const appendField = useCallback(
- (type: string) => {
- const last = visibleSortedFields[visibleSortedFields.length - 1];
- const nextIndex = formatIndex(maxLocalIndex() + 1);
-
- const lastPos = (last?.options as any)?.position as { x: number; y: number } | undefined;
- const position = lastPos
- ? { x: lastPos.x, y: lastPos.y + 200 }
- : { x: 300, y: visibleSortedFields.length * 200 + 80 };
-
- const tempId = `new-${Date.now()}`;
- const newField: LocalField = {
- id: tempId,
- formId,
- // Same assignment the canvas drop uses. Omitting it was the bug: the
- // question landed outside every segment and the outline's segment
- // filter hid it immediately.
- segmentId: nextSegmentId(),
- label: "",
- labelKey: "field",
- placeholder: null,
- index: nextIndex,
- isRequired: false,
- type: type as any,
- options: { position } as any,
- description: null,
- version: 0,
- createdAt: new Date(),
- updatedAt: new Date(),
- _isNew: true,
- };
- setLocalFields((prev) => [...prev, newField]);
- setDirtyIds((prev) => new Set(prev).add(tempId));
- setSelectedNodeId(tempId);
- },
- [visibleSortedFields, formId, maxLocalIndex, nextSegmentId],
- );
-
- const handleMobileAddField = useCallback(
- (type: string) => {
- appendField(type);
- setMobileAddOpen(false);
- setMobileEditorOpen(true);
- },
- [appendField],
- );
+ setSelectedSegmentId,
+ mobileSegmentsOpen,
+ setMobileSegmentsOpen,
+ isSaving,
+ justSaved,
+ showUnsavedDialog,
+ setShowUnsavedDialog,
+ pendingNavRef,
+ isDirtyRef,
+ isDirty,
+ handleSave,
+ updateLocal,
+ visibleSegments,
+ updateSegmentLocal,
+ handleAddSegment,
+ handleMoveSegment,
+ handleDeleteSegment,
+ nodes,
+ onNodesChange,
+ edges,
+ onEdgesChange,
+ selectedNodeId,
+ setSelectedNodeId,
+ isLocked,
+ setIsLocked,
+ branchingFieldId,
+ setBranchingFieldId,
+ rulesForField,
+ handleAddRule,
+ handleUpdateRule,
+ handleDeleteRule,
+ handleDuplicateRule,
+ handleMoveRule,
+ handleAddCondition,
+ handleUpdateCondition,
+ handleDeleteCondition,
+ view,
+ handleViewChange,
+ label,
+ setLabel,
+ placeholder,
+ setPlaceholder,
+ isRequired,
+ description,
+ setDescription,
+ optionsList,
+ setOptionsList,
+ zoomIn,
+ zoomOut,
+ fitView,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onNodeClick,
+ onPaneClick,
+ onNodeDragStop,
+ handleRequiredChange,
+ handleDeleteField,
+ mobileAddOpen,
+ setMobileAddOpen,
+ mobileEditorOpen,
+ visibleSortedFields,
+ handleMobileTapField,
+ handleCloseMobileEditor,
+ handleMobileMove,
+ appendField,
+ handleMobileAddField,
+ segmentQuestionCounts,
+ unassignedQuestionCount,
+ branchTargetSegments,
+ activeRuleCount,
+ draftFlow,
+ flowLabels,
+ selectedFieldRuleSummaries,
+ selectedFieldIncompleteRules,
+ isSelectedFieldLastInSegment,
+ selectedField,
+ publishForm,
+ deleteFormAsync,
+ } = useBuilderState();
if (formLoading || fieldsLoading) {
return (
@@ -1505,14 +212,6 @@ function BuilderCanvas() {
-
- {/* Palette and segment list share a column: both answer "what am I
- adding, and where does it go", and the segment filter has to be
- visible while dropping fields for the drop target to make
- sense. */}
- {/* Narrower at the lg breakpoint. At 1024px a fixed 256px here plus
- the 288px inspector left the canvas under 480px, which is not
- enough to read a field node in. Both rails widen again at xl. */}
{
if (!selectedField) return;
updateLocal(selectedField.id, { segmentId });
- // Follow the question to its new segment. The canvas is filtered
- // by segment, so leaving the filter where it was would make the
- // question the author just moved disappear while its inspector
- // stayed open beside the empty canvas.
if (segmentId && selectedSegmentId !== null) setSelectedSegmentId(segmentId);
}}
ruleSummaries={selectedFieldRuleSummaries}
@@ -1677,15 +372,6 @@ function BuilderCanvas() {
- {/* Segments, collapsed by default.
- *
- * The panel used to exist only inside the desktop-only column,
- * which meant segments and everything reached through them were
- * simply absent on a phone — a form split into pages couldn't be
- * edited on the device most likely to be filling it in. Reusing the
- * same component rather than writing a phone-specific one keeps the
- * two from drifting; collapsing it keeps a four-segment list from
- * eating the screen above the outline. */}
{
@@ -1777,9 +459,6 @@ function BuilderCanvas() {
onEditBranching={
selectedField
? () => {
- // Close the sheet first: both are fixed overlays, and leaving
- // the sheet mounted underneath traps scrolling on it while
- // the dialog is the thing being scrolled.
handleCloseMobileEditor();
setBranchingFieldId(selectedField.id);
}
@@ -1842,10 +521,6 @@ function BuilderCanvas() {
setShowSettingsDialog(false)}
diff --git a/apps/web/app/dashboard/sketches/page.tsx b/apps/web/app/dashboard/sketches/page.tsx
index 0a9d1b0..1559357 100644
--- a/apps/web/app/dashboard/sketches/page.tsx
+++ b/apps/web/app/dashboard/sketches/page.tsx
@@ -6,7 +6,6 @@ import {
ArrowLeft,
ArrowRight,
ArrowUpRight,
- ChevronDown,
Clock,
Plus,
Search,
@@ -21,6 +20,7 @@ import { useDebounce } from "~/hooks/useDebounce";
import { trpc } from "~/trpc/client";
import { toast } from "sonner";
import { ShareCollaboratorsDialog } from "~/components/builder/ShareCollaboratorsDialog";
+import { CustomSelect } from "~/components/ui/CustomSelect";
/* ─── helpers ────────────────────────────────────────────────────────── */
@@ -142,8 +142,6 @@ export default function SketchesPage() {
- {/* Search and the two selects sit on one rule, squared and black-edged
- like the rest of the chrome. */}
-
- {hasActiveFilters ? "Filtered" : "All forms"}
-
+ {hasActiveFilters ? "Filtered" : "All forms"}
{processedForms.length} {processedForms.length === 1 ? "result" : "results"}
@@ -261,35 +257,42 @@ export default function SketchesPage() {
-
Permanent action
-
- Delete form
- .
-
-
-
- “{confirmDeleteForm.title}”
- {" "}
- and all its fields and submissions will be permanently removed. This cannot be undone.
-
-
-
-
setConfirmDeleteId(null)}
- disabled={isDeleting}
- className="cf-dark-btn-outline px-4 py-2 text-[13px] disabled:opacity-50"
+
+ Permanent action
+
+
+ Delete form
+ .
+
+
- Cancel
-
-
handleDelete(confirmDeleteId)}
- disabled={isDeleting}
- className="cf-btn px-5 py-2 text-[13px] disabled:opacity-50" style={{ background: "var(--c-red)" }}
- >
-
- {isDeleting ? "Deleting..." : "Delete"}
-
-
+
+ “{confirmDeleteForm.title}”
+ {" "}
+ and all its fields and submissions will be permanently removed. This cannot be
+ undone.
+
+
+
+ setConfirmDeleteId(null)}
+ disabled={isDeleting}
+ className="cf-dark-btn-outline px-4 py-2 text-[13px] disabled:opacity-50"
+ >
+ Cancel
+
+ handleDelete(confirmDeleteId)}
+ disabled={isDeleting}
+ className="cf-btn px-5 py-2 text-[13px] disabled:opacity-50"
+ style={{ background: "var(--c-red)" }}
+ >
+
+ {isDeleting ? "Deleting..." : "Delete"}
+
+
@@ -324,21 +327,11 @@ function ToolbarSelect({
{label}
- onChange(e.target.value)}
- className="h-11 w-full cursor-pointer appearance-none border border-(--cf-line-strong) bg-(--cf-cream-2) pr-9 pl-3.5 font-mono text-[11px] font-bold tracking-[0.14em] uppercase transition-shadow focus:shadow-[3px_3px_0_0_var(--cf-line-strong)] focus:outline-none sm:w-auto"
- >
- {options.map((o) => (
-
- {o.label}
-
- ))}
-
-
);
@@ -443,7 +436,8 @@ function FormCard({ form, onDelete, onShare }: FormCardProps) {
}}
title="Delete form"
aria-label="Delete form"
- className="shrink-0 cursor-pointer border border-transparent p-1.5 transition-colors hover:border-(--cf-line-strong)" style={{ color: "var(--cf-ink-soft)" }}
+ className="shrink-0 cursor-pointer border border-transparent p-1.5 transition-colors hover:border-(--cf-line-strong)"
+ style={{ color: "var(--cf-ink-soft)" }}
>
@@ -473,7 +467,8 @@ function FormCard({ form, onDelete, onShare }: FormCardProps) {
{isPublished ? (
Open
@@ -529,17 +524,11 @@ function EmptyState({
{hasFilters && (
-
+
Clear filters
)}
-
+
New form
diff --git a/apps/web/app/forms/[formId]/page.tsx b/apps/web/app/forms/[formId]/page.tsx
index 21eac13..5f33b4f 100644
--- a/apps/web/app/forms/[formId]/page.tsx
+++ b/apps/web/app/forms/[formId]/page.tsx
@@ -32,6 +32,7 @@ import { FormDraftNotice, type DraftStatus } from "~/components/forms/FormDraftN
import { FormLoadingState } from "~/components/forms/FormLoadingState";
import { FormErrorState } from "~/components/forms/FormErrorState";
import { FormThankYou } from "~/components/forms/FormThankYou";
+import { isUploadAnswerComplete, stripUploadSecrets } from "~/lib/upload";
import { FormQuestion } from "~/components/forms/FormQuestion";
import { FormHeader } from "~/components/forms/FormHeader";
import { FormFooter } from "~/components/forms/FormFooter";
@@ -69,15 +70,6 @@ export default function PublicFormPage() {
const params = useParams();
const formId = params.formId as string;
- /**
- * Preview mode. Set by the builder's Preview link (`?preview=1`).
- *
- * Everything about filling the form behaves normally, including Submit —
- * what changes is that nothing leaves the browser: no response, no draft, no
- * answer tracking, and the one-response-per-visitor lockout is ignored so the
- * author can test the flow as many times as they like. The banner says so, at
- * the top, for the whole session.
- */
const searchParams = useSearchParams();
const isPreview = searchParams.get("preview") === "1";
@@ -85,26 +77,9 @@ export default function PublicFormPage() {
const { submitForm, isPending } = useSubmitForm();
const { recordFieldAnswer } = useRecordFieldAnswer();
- /* ─── Drafts ──────────────────────────────────────────────────────────
- *
- * Only for a signed-in respondent: a draft has to be tied to an account,
- * because that account is how we recognise them when they come back. An
- * anonymous visitor gets no draft rather than a fake one that dies with the
- * browser session. Never in preview — the author's test answers are not
- * progress worth keeping. */
const { userInfo, isPending: sessionPending } = useGetLoggedInUserInfo();
const canSaveDraft = !!userInfo && !isPreview;
- /* ─── Who may respond ─────────────────────────────────────────────────
- *
- * Evaluated with the same helpers the server enforces with, so the gate the
- * respondent sees and the rule that rejects them can't disagree. Checked
- * before they start rather than at submit: asking somebody to answer fifty
- * questions and only then telling them to sign in wastes their time and
- * loses the answers.
- *
- * Preview skips all of it — the author is testing the form, not qualifying
- * to answer it. */
const accessRules = useMemo(
() => ({
requireSignIn: !!form?.requireSignIn,
@@ -123,11 +98,6 @@ export default function PublicFormPage() {
const needsSignIn = !isPreview && requiresSignIn(accessRules);
const signedInEmail = userInfo?.email ?? null;
- /* Held back until the session has resolved. `userInfo` is null both for a
- * visitor who isn't signed in and for one whose session is still in flight,
- * and treating those the same would flash the sign-in wall at somebody who is
- * already signed in. On a form that doesn't need sign-in we don't wait at
- * all — the session is irrelevant there. */
const gateResolving = needsSignIn && sessionPending;
const mustSignIn = needsSignIn && !gateResolving && !signedInEmail;
@@ -136,15 +106,11 @@ export default function PublicFormPage() {
!!signedInEmail &&
!isEmailDomainAllowed(signedInEmail, accessRules.allowedEmailDomains);
- /** Sign-in link that comes back to this exact form, preview flag and all. */
const signInHref = useMemo(() => {
const back = isPreview ? `/forms/${formId}?preview=1` : `/forms/${formId}`;
return `/signIn?redirect=${encodeURIComponent(back)}`;
}, [formId, isPreview]);
- /** Only forms that ask for a single response should lock the browser. With
- * multiple responses allowed — the default — a returning visitor is simply
- * answering again. */
const locksToOneResponse = !!form?.oneResponsePerRespondent;
const { draft, isFetched: draftFetched } = useGetDraft(formId, canSaveDraft);
@@ -157,38 +123,15 @@ export default function PublicFormPage() {
const [answers, setAnswers] = useState>({});
- /**
- * The pages this respondent has actually been shown, in order. The last
- * entry is the current page.
- *
- * This began as a `currentQuestionIndex` counter, became a path of question
- * ids when branching arrived, and is now a path of page indexes. The reason
- * it tracks pages rather than questions: a page can hold a whole segment, so
- * "where am I" and "what did I come from" are page-level facts. Questions
- * are recovered from the pages when they're needed — for validation, for the
- * answers that belong to the run, and for the thank-you summary.
- *
- * Position still can't be derived, because branching means two respondents
- * on their third page can be on different pages.
- */
const [pagePath, setPagePath] = useState([]);
const [submitted, setSubmitted] = useState(false);
const [siteRating, setSiteRating] = useState(null);
const [alreadySubmitted, setAlreadySubmitted] = useState(false);
- /**
- * An access rule the server rejected us on at submit time.
- *
- * The gate below normally catches this before the respondent starts, so this
- * only fires when the session changed underneath them — it expired while they
- * were filling, or they signed into a different account in another tab.
- * Recording it in state means submit-time and load-time refusals render the
- * same screen instead of one being a toast and the other a wall.
- */
- const [accessError, setAccessError] = useState<
- "sign-in-required" | "domain-not-allowed" | null
- >(null);
+ const [accessError, setAccessError] = useState<"sign-in-required" | "domain-not-allowed" | null>(
+ null,
+ );
const formOpenedAtRef = React.useRef(Date.now());
const visitorIdRef = React.useRef(null);
@@ -198,25 +141,6 @@ export default function PublicFormPage() {
: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
- /**
- * Storage key for the "you've already responded" shortcut, or null when there
- * is nothing to remember.
- *
- * Three conditions, each for its own reason:
- *
- * - Null when the form takes multiple responses, which is now the default. A
- * returning visitor there is simply answering again, and the old
- * unconditional lockout turned that into a wall.
- * - Null in preview, so a lockout from a real submission doesn't stop the
- * author walking their own form.
- * - Scoped to the account rather than the form alone. One-response forms
- * always require sign-in, so there is always an account to scope by — and
- * without it a shared computer would lock the second person out of a form
- * the first person filled.
- *
- * The server is what actually enforces one response per account. This only
- * saves a respondent from answering fifty questions before being told.
- */
const submittedStorageKey = useMemo(() => {
if (!formId || isPreview || !locksToOneResponse || !userInfo?.id) return null;
return `cf_submitted_${formId}_${userInfo.id}`;
@@ -233,7 +157,6 @@ export default function PublicFormPage() {
}
}, [submittedStorageKey]);
- /** No-op unless the form is actually limited to one response per account. */
const rememberSubmitted = useCallback(() => {
if (!submittedStorageKey) return;
try {
@@ -249,14 +172,6 @@ export default function PublicFormPage() {
visitorIdRef.current = getOrCreateVisitorId(formId);
}, [formId]);
- /**
- * A toggle's starting position, as the author configured it.
- *
- * Pulled out of the effect below so the two "begin again" paths — discarding
- * a draft, and answering a second time — can reset to these rather than to an
- * empty object. Resetting to `{}` left every toggle unset, which is not the
- * form the author designed.
- */
const toggleDefaults = useMemo(() => {
const defaults: Record = {};
(form?.fields ?? []).forEach((field) => {
@@ -267,23 +182,10 @@ export default function PublicFormPage() {
return defaults;
}, [form?.fields]);
- // Applied under `prev`, so a toggle the respondent has already changed — or
- // one restored from a draft — is never pulled back to its default.
useEffect(() => {
setAnswers((prev) => ({ ...toggleDefaults, ...prev }));
}, [toggleDefaults]);
- /**
- * Restore a saved draft, once.
- *
- * The ref guard is the important part: this must run exactly once per mount.
- * Without it a refetch would re-apply the server's copy over answers typed
- * since, which reads as the form undoing your work.
- *
- * Draft answers are spread last so they win over the toggle defaults above —
- * a toggle the respondent deliberately switched off must not be reset to its
- * default on resume.
- */
useEffect(() => {
if (draftRestoredRef.current || !canSaveDraft || !draftFetched) return;
draftRestoredRef.current = true;
@@ -297,10 +199,6 @@ export default function PublicFormPage() {
setResumedFrom(draft.updatedAt);
}, [draft, draftFetched, canSaveDraft]);
- /* Autosave. Debounced rather than per-keystroke: a fifty-question form would
- * otherwise be a request per character. 1200ms is long enough to cover normal
- * typing and short enough that closing the tab a moment after answering still
- * keeps the answer — and `flush()` covers the rest. */
const persistDraft = useCallback(
(nextAnswers: Record, nextPagePath: number[]) => {
if (!canSaveDraft) return;
@@ -310,9 +208,6 @@ export default function PublicFormPage() {
saveDraftAsync({ formId, values: nextAnswers, pagePath: nextPagePath })
.then(() => setDraftStatus("saved"))
.catch(() => {
- // Autosave is best-effort. A failure here must not interrupt someone
- // mid-form with a toast about a feature they didn't ask for; the
- // notice simply stops claiming the work is saved.
setDraftStatus("idle");
});
},
@@ -325,14 +220,10 @@ export default function PublicFormPage() {
cancel: cancelDraftSave,
} = useDebouncedCallback(persistDraft, 1200);
- /* Per-question answer tracking, also debounced. This used to fire only when
- * the respondent pressed Next, which meant a form abandoned mid-question
- * recorded nothing about the question they gave up on — the one thing the
- * drop-off report most needs to know. */
const trackAnswer = useCallback(
(fieldId: string, value: unknown) => {
if (isPreview) return;
- recordFieldAnswer({ formId, fieldId, value });
+ recordFieldAnswer({ formId, fieldId, value: stripUploadSecrets(value) });
},
[isPreview, formId, recordFieldAnswer],
);
@@ -342,17 +233,12 @@ export default function PublicFormPage() {
const handleFieldChange = (fieldId: string, value: any) => {
setAnswers((prev) => {
const next = { ...prev, [fieldId]: value };
- // Both saves are queued from here rather than from an effect on
- // `answers`, so they see the value that caused them and can't fire on a
- // restore or on the toggle defaults.
queueDraftSave(next, pagePath);
return next;
});
queueTrackAnswer(fieldId, value);
};
- // Flattened traversal order plus the rule lookups. Rebuilt only when the
- // form payload changes, since every navigation decision reads it.
const flow = useMemo(
() =>
buildFlow(
@@ -365,20 +251,6 @@ export default function PublicFormPage() {
const totalQuestions = flow.order.length;
- /**
- * How many questions share a page, resolved from the author's setting.
- *
- * AUTO — the default — reads the form's shape: a form with one segment or
- * none is a single run of questions and reads best one at a time, while a
- * form the author split into several segments already has its pages, so we
- * show them. That means adding a second segment changes the respondent's
- * experience without the author having to find a setting.
- *
- * The rule count goes in because a single-page layout can't carry branching:
- * a rule that picks the next page needs there to be one. `resolveLayout`
- * paginates anyway in that case rather than honour a setting that would make
- * the page rearrange itself as the respondent answered.
- */
const layout = useMemo(
() =>
resolveLayout(
@@ -389,17 +261,7 @@ export default function PublicFormPage() {
[form?.questionLayout, flow.segments.length, form?.logicRules],
);
- /**
- * `answers` is a dependency because ALL_AT_ONCE renders the questions the
- * current answers lead through — with branching on, a question on a road not
- * taken has no business being on the page. The other two layouts ignore
- * answers here and are unaffected.
- */
const pages = useMemo(() => buildPages(flow, layout, answers), [flow, layout, answers]);
-
- // Start the respondent on the first page once the form has loaded. Guarded
- // on `pagePath.length` so a background refetch can't reset someone mid-form
- // back to the beginning.
useEffect(() => {
if (pagePath.length > 0 || pages.length === 0) return;
setPagePath([0]);
@@ -408,7 +270,6 @@ export default function PublicFormPage() {
const currentPageIndex = pagePath[pagePath.length - 1] ?? 0;
const currentPage = pages[currentPageIndex];
- /** Questions on the current page, resolved to field objects. */
const currentFields = useMemo(
() =>
(currentPage?.fieldIds ?? [])
@@ -417,8 +278,6 @@ export default function PublicFormPage() {
[currentPage, flow],
);
- /** Whole-form question numbering for the Q01 eyebrow. Page-relative
- * numbering would restart at 1 on every page. */
const questionNumbers = useMemo(() => {
const numbers: Record = {};
flow.order.forEach((field, i) => {
@@ -427,9 +286,6 @@ export default function PublicFormPage() {
return numbers;
}, [flow]);
- // Recomputed on every answer change, which is the point: choosing an option
- // that ends the form flips the button to "Submit" immediately rather than
- // after the respondent commits to it.
const nextPage = useMemo(
() =>
pages.length === 0
@@ -446,18 +302,6 @@ export default function PublicFormPage() {
const isLast = nextPage.kind === "end";
- /**
- * Progress over the route this respondent is on, not over the whole form.
- *
- * Dividing by the total would be meaningless once branches can skip most of
- * a form — a three-page path through a twenty-page form would crawl to 15%
- * and then jump straight to done. Dividing by the length of their own route
- * keeps it honest, at the cost of shifting when a branch changes what's
- * ahead.
- *
- * ALL_AT_ONCE has exactly one page, where that calculation only ever reads
- * 0% or 100%. There, answered-out-of-visible is the only measure that moves.
- */
const estimatedRemainingPages = useMemo(
() =>
pages.length === 0
@@ -491,15 +335,11 @@ export default function PublicFormPage() {
return total > 0 ? Math.round((pagePath.length / total) * 100) : 0;
}, [pages.length, layout, currentFields, answers, pagePath.length, estimatedRemainingPages]);
- /** Every question the respondent has actually been shown, in route order.
- * Derived from the visited pages, so a multi-question page contributes all
- * of its questions. */
const visitedFieldIds = useMemo(
() => pagePath.flatMap((pageIndex) => pages[pageIndex]?.fieldIds ?? []),
[pagePath, pages],
);
- // Used by the thank-you summary so it mirrors the submitted payload exactly.
const answeredPathFields = useMemo(
() =>
visitedFieldIds
@@ -508,9 +348,6 @@ export default function PublicFormPage() {
[flow, visitedFieldIds],
);
- /* "Segment 2 of 3 · Your details" above the page. Null when there's nothing
- * meaningful to say — an unsegmented form, or a single-page layout where a
- * page counter would just be noise. */
const segmentLabel = useMemo(() => {
if (!currentPage?.segment) return null;
const position = flow.segments.findIndex((s) => s.id === currentPage.segment?.id);
@@ -518,13 +355,6 @@ export default function PublicFormPage() {
return `Segment ${position + 1} of ${flow.segments.length} · ${currentPage.segment.title}`;
}, [currentPage, flow.segments]);
- /**
- * Validate one question. Returns an error message, or null when it passes.
- *
- * Extracted because a page can hold many questions and every one of them has
- * to pass before the page advances — previously there was only ever one to
- * check, so the rules were written inline.
- */
const validateField = (field: FlowField): string | null => {
const value = answers[field.id];
const missing =
@@ -533,6 +363,25 @@ export default function PublicFormPage() {
value === "" ||
(Array.isArray(value) && value.length === 0);
+ if (field.type === "FILE_UPLOAD") {
+ if (missing) {
+ return field.isRequired
+ ? `Please attach a file for the required field: “${field.label}”`
+ : null;
+ }
+
+ if (!isUploadAnswerComplete(value)) {
+ const refs = Array.isArray(value) ? value : [value];
+ const failed = refs.find((ref: any) => ref?.status === "failed");
+
+ return failed
+ ? `“${field.label}” — that file could not be attached. Remove it and try again.`
+ : `“${field.label}” is still uploading — give it a moment.`;
+ }
+
+ return null;
+ }
+
if (field.isRequired && missing) {
return `Please answer the required field: “${field.label}”`;
}
@@ -558,8 +407,6 @@ export default function PublicFormPage() {
const handleNext = () => {
if (currentFields.length === 0) return;
- // Every question on the page, in the order they appear, so the reported
- // problem is the first one the respondent will see when they look up.
for (const field of currentFields) {
const error = validateField(field);
if (error) {
@@ -568,9 +415,6 @@ export default function PublicFormPage() {
}
}
- // Per-question analytics for the whole page. Debounced tracking already
- // covers questions as they're answered; this catches the rest — a TOGGLE
- // left at its default was never "changed", so nothing queued it.
if (!isPreview) {
for (const field of currentFields) {
const value = answers[field.id];
@@ -580,7 +424,7 @@ export default function PublicFormPage() {
value !== "" &&
!(Array.isArray(value) && value.length === 0);
if (hasAnswer || field.type === "TOGGLE") {
- recordFieldAnswer({ formId, fieldId: field.id, value });
+ recordFieldAnswer({ formId, fieldId: field.id, value: stripUploadSecrets(value) });
}
}
}
@@ -588,26 +432,12 @@ export default function PublicFormPage() {
if (nextPage.kind === "page") {
const advanced = [...pagePath, nextPage.pageIndex];
setPagePath(advanced);
- // Save on page turn without waiting out the debounce. Leaving a page is
- // the moment a respondent is most likely to walk away, and it's also the
- // only way the stored page position stays in step with the answers.
if (canSaveDraft) persistDraft(answers, advanced);
} else {
- // Only answers for questions on the route actually taken. A respondent
- // who answered, went back, and took a different branch has a stale
- // answer sitting in state for a question they were never finally
- // asked; submitting it would record a response to a question that
- // wasn't part of this run and skew every per-question analytic.
const payloadValues = answersOnPath(answers, visitedFieldIds).map(
({ formFieldId, value }) => ({ formFieldId, value }),
);
- /* Preview stops here. The author gets the full completion experience —
- * the thank-you screen, their own answers summarised — with no request
- * made, so nothing is stored and the form's response count is untouched.
- * Returning before `submitForm` rather than having the server discard a
- * "preview" flag keeps that guarantee on this side of the network, where
- * it can't be defeated by a crafted request. */
if (isPreview) {
cancelDraftSave();
setSubmitted(true);
@@ -634,21 +464,12 @@ export default function PublicFormPage() {
onSuccess: () => {
setSubmitted(true);
rememberSubmitted();
- // The draft has served its purpose. Cancel the pending autosave
- // first, or it would fire after the delete and resurrect a draft
- // for a form the respondent has already completed.
cancelDraftSave();
if (canSaveDraft) void deleteDraftAsync({ formId }).catch(() => {});
toast.success("Thanks — your response was submitted");
},
onError: (err) => {
- /* The service's sentinels, matched by exact string. Each one is a
- * rule the respondent can act on, so each gets the screen that says
- * what to do — falling through to a toast would show them a raw
- * error code and no way forward. */
switch (err.message) {
- // Both mean "we already have your response": the first from the
- // idempotency/visitor path, the second from the per-account check.
case "ALREADY_SUBMITTED":
case "ALREADY_RESPONDED":
rememberSubmitted();
@@ -669,38 +490,16 @@ export default function PublicFormPage() {
}
};
- /**
- * Step back to the page we actually came from.
- *
- * Popping the path rather than decrementing an index is what makes Back
- * correct across a jump: after skipping pages 2-5, the page before 6 is 1,
- * and only the path knows that.
- *
- * Answers on the page being left are kept. If the respondent changes one and
- * comes forward again, the routing re-runs and may send them somewhere new —
- * and anything stranded by that re-route is filtered out at submit time by
- * `answersOnPath`.
- */
const handleBack = () => {
setPagePath((prev) => {
if (prev.length <= 1) return prev;
const back = prev.slice(0, -1);
- // Keep the stored position in step, so resuming doesn't drop them on a
- // page they had already navigated away from.
if (canSaveDraft) persistDraft(answers, back);
return back;
});
};
const canGoBack = pagePath.length > 1;
-
- /**
- * Discard the restored draft and begin again.
- *
- * Offered because restoring answers silently is unsettling — the form appears
- * to already know things about you — and the only honest remedy is a visible
- * way to throw them away.
- */
const handleStartOver = useCallback(() => {
cancelDraftSave();
setAnswers({ ...toggleDefaults });
@@ -711,20 +510,6 @@ export default function PublicFormPage() {
toast("Starting over — your saved answers were discarded");
}, [cancelDraftSave, toggleDefaults, pages.length, canSaveDraft, deleteDraftAsync, formId]);
- /**
- * Answer the form a second time, without a reload.
- *
- * Offered only when the form takes multiple responses, and in preview
- * regardless — the author is testing, and being sent away after one pass is
- * exactly what they don't want.
- *
- * The idempotency key has to be replaced, not reused. It exists so a
- * double-click can't record two responses, which means submitting the second
- * response under the first one's key would be read as a replay: the server
- * would return the original submission and the respondent's new answers would
- * be silently dropped. `formOpenedAtRef` restarts for the same reason — the
- * time-spent figure should describe this response, not both.
- */
const handleRespondAgain = useCallback(() => {
cancelDraftSave();
setAnswers({ ...toggleDefaults });
@@ -741,20 +526,13 @@ export default function PublicFormPage() {
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
formOpenedAtRef.current = Date.now();
- // A fresh draft would otherwise be restored on top of the blank form the
- // moment the next answer queues a save.
draftRestoredRef.current = true;
window.scrollTo({ top: 0, behavior: "smooth" });
}, [cancelDraftSave, toggleDefaults, pages.length]);
- /** Whether to offer it at all. Preview included so the author can loop. */
const canRespondAgain = isPreview || !locksToOneResponse;
- /* Save immediately when the tab is hidden or closed. The debounce is the
- * common path; this is the one that matters when someone closes the laptop
- * mid-answer. `visibilitychange` rather than `beforeunload` because mobile
- * browsers frequently never fire the latter. */
useEffect(() => {
if (!canSaveDraft) return;
@@ -771,8 +549,6 @@ export default function PublicFormPage() {
if (isLoading) return ;
if (error || !form) return ;
- // Sign-in-gated form, session still in flight. Keep loading rather than
- // guessing — see `gateResolving`.
if (gateResolving) return ;
if (!form.isPublished) return ;
if (!form.isOpen) return ;
@@ -780,13 +556,6 @@ export default function PublicFormPage() {
return ;
}
- /* Access gates, ordered after the form-level ones: whether the form is even
- * taking responses doesn't depend on who's asking, so a closed form should say
- * "closed" rather than demand a sign-in first and only then admit it's shut.
- *
- * Both are checked before the questions render. Letting somebody fill fifty
- * fields and refusing them at Submit wastes their time and loses the answers,
- * and for an anonymous respondent there's no draft to recover from. */
if (mustSignIn || accessError === "sign-in-required") {
return ;
}
@@ -795,7 +564,7 @@ export default function PublicFormPage() {
);
}
@@ -808,7 +577,6 @@ export default function PublicFormPage() {
- {/* Outside the padded column so it spans the full width and can stick. */}
{isPreview &&
}
@@ -871,10 +639,6 @@ export default function PublicFormPage() {
1 ? "Next section" : "Next"}
+ formId={formId}
+ isPreview={isPreview}
/>
)}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index d853b83..edcf3ce 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -61,60 +61,27 @@
html {
scroll-behavior: smooth;
}
- /* Anchored landing sections offset themselves so the fixed nav doesn't
- overlap their top when scrolled to via in-page links. */
section[id] {
scroll-margin-top: 96px;
}
+ button {
+ cursor: pointer;
+ }
}
-/* ============================================================
- CanvasFlow — app theme (dashboard, builder, analytics, public
- form filling)
- ------------------------------------------------------------
- These `--cf-*` tokens are referenced about a thousand times across
- app/dashboard, components/builder, components/analytics and
- components/forms. Rather than rewrite those call sites to the
- `hex-*` names the landing and auth screens use, the tokens are
- repointed at the same palette here. One edit re-skins the whole app
- surface and the two themes cannot drift apart, because they now
- resolve to the same values.
-
- The names are kept deliberately — `--cf-orange` is no longer orange.
- Renaming it would touch every one of those call sites for no visual
- gain, so the misnomer is the cheaper trade. It is the accent role:
- primary fill, accent text, and emphasis.
- ============================================================ */
:root {
- /* Paper stock. Cool grey rather than the warm cream the marketing site
- uses: the app is a working surface, and a neutral ground keeps the blue
- accent and the data-viz hues honest instead of pulling them yellow.
- The token names stay `cream` — see the note above about renaming. */
--cf-cream: #f0f0f0;
--cf-cream-2: #e8e8e8;
- /* Ink ramp, matching --hex-ink / --hex-ink-soft. */
--cf-ink: #1a1d29;
--cf-ink-soft: #5b6070;
- /* Hairline vs structural edge. `--cf-line-strong` goes near-black for
- the same reason the landing did: inputs, cards and buttons should
- read as drawn boxes rather than soft panels. */
--cf-line: rgba(26, 29, 41, 0.16);
--cf-line-strong: #1a1d29;
- /* Accent. The app keeps a coloured primary where the marketing site uses
- plain ink — a dashboard of black-on-cream with no accent has nothing to
- lead the eye.
-
- This is the same blue the footer wordmark already used, rather than a
- second near-identical one. Two blues a few degrees apart read as a
- mistake; one reads as a system. */
--cf-orange: #2d5cf6;
--cf-orange-hover: #2449d0;
- /* Destructive. Was hardcoded as #c1281d at every call site; named here so
- delete buttons across the app can't drift apart. */
--cf-danger: #c1281d;
}
@@ -136,9 +103,6 @@
color: var(--cf-ink);
}
-/* Headings. Was Fraunces at 400 — a serif display face. Now the same
- tight-tracked Inter semibold the landing and auth headlines use, so a
- heading reads the same on the dashboard as it does on the way in. */
.cf-display {
font-family:
var(--font-inter),
@@ -158,18 +122,6 @@
font-weight: 500;
}
-/* ---- Boxy app chrome ----------------------------------------------
- The same radius reset the landing gets, applied to the app surface so
- the two match. Squares the corner-radius utilities in one place
- instead of rewriting ~134 `rounded-*` call sites across the
- dashboard, builder, analytics and form-filling screens.
-
- `rounded-full` is deliberately absent: avatars, status dots, radio
- marks, toggle pills and spinners all rely on it, and flattening those
- would read as a bug rather than a style.
-
- Two class selectors outrank Tailwind's single-class utility, which is
- what lets this win without `!important`. */
.cf-landing .rounded,
.cf-landing .rounded-sm,
.cf-landing .rounded-md,
@@ -183,30 +135,17 @@
border-radius: var(--hex-radius);
}
-/* ============================================================
- App surface recipes
- ------------------------------------------------------------
- The dashboard chrome: drawn boxes with a hard offset shadow that
- collapses on interaction, so a card behaves like a physical tile being
- pressed flat rather than fading. Declared here because the pattern
- repeats across the overview, the forms list and the empty states.
- ============================================================ */
@layer components {
- /* Panel with a black edge and a hard drop. */
.cf-panel {
background: var(--cf-cream-2);
border: 1px solid var(--cf-line-strong);
border-radius: var(--hex-radius);
}
- /* Adds the offset shadow. Split from `.cf-panel` because some panels are
- flush inside another box and must not cast one. */
.cf-raised {
box-shadow: 4px 4px 0 0 var(--cf-line-strong);
}
- /* Interactive tile: the shadow closes as the tile slides into it, so the
- travel and the shadow loss cancel out and nothing below reflows. */
.cf-press {
transition:
box-shadow 0.15s ease,
@@ -239,8 +178,6 @@
background: var(--cf-orange-hover);
}
- /* Secondary action: transparent so the paper reads through, inverting to
- ink on hover the same way the landing's outlined buttons do. */
.cf-btn-outline {
display: inline-flex;
align-items: center;
@@ -261,9 +198,6 @@
color: var(--cf-cream);
}
- /* Underlined tab, as in the analytics mock. The active rule sits on the
- container's bottom border rather than beside it, so the strip reads as
- one ruled line with a segment inked in. */
.cf-tab {
position: relative;
padding: 0 0 10px;
@@ -290,7 +224,6 @@
background: var(--cf-ink);
}
- /* Mono field label / count, matching the landing's eyebrow scale. */
.cf-meta {
font-family: var(--font-mono-cf), ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 10px;
@@ -300,24 +233,10 @@
color: var(--cf-ink-soft);
}
- /* ---- Builder panes -------------------------------------------------
- The builder is a light surface, so these are the light counterparts of
- the `.cf-dark-*` recipes. They exist because the `--cfd-*` tokens are
- declared inside `.cf-dark` and therefore resolve to nothing out here:
- an undefined custom property makes the declaration invalid at
- computed-value time, so `border-color` silently becomes `currentColor`
- and `background` becomes transparent. That is how a border goes
- missing without anything erroring. */
-
- /* A builder side pane. Edges are owned by its neighbours (the field
- sidebar's border-r and the canvas's border-r) so panes never draw two
- lines against each other. */
.cf-rail {
background: var(--cf-cream-2);
}
- /* Chrome row at the top of a pane. Fixed height so the rule lines up
- across the sidebar, canvas and inspector. */
.cf-pane-bar {
display: flex;
height: 40px;
@@ -330,7 +249,6 @@
border-bottom: 1px solid var(--cf-line-strong);
}
- /* Destructive action. Outlined until hover, then it commits to red. */
.cf-btn-danger {
display: inline-flex;
align-items: center;
@@ -429,18 +347,6 @@
background-size: 20px 20px;
}
-/* ============================================================
- Dark popups
- ------------------------------------------------------------
- Dialogs invert to near-black instead of sitting on the same paper as
- the page. On a light working surface a light modal has to fight the
- backdrop to read as "on top"; going dark separates it instantly and
- lets the scrim stay light enough that the page behind is still legible
- as context.
-
- Tokens are local to the dark surface so the components inside can be
- written once against `--cfd-*` and not care which ground they land on.
- ============================================================ */
.cf-dark {
--cfd-bg: #101014;
--cfd-bg-2: #16161c;
@@ -506,8 +412,6 @@
background: rgba(255, 255, 255, 0.08);
}
- /* Card inside a dark popup. The crop marks are drawn with two gradient
- pairs rather than four child elements, so a card stays a single node. */
.cf-dark-card {
position: relative;
background: var(--cfd-bg-2);
@@ -584,19 +488,6 @@
}
}
-/* ============================================================
- Dialogs
- ------------------------------------------------------------
- Light, squared and black-edged, matching the share panel in the landing
- mock: a chrome bar naming the dialog, mono section labels, bordered
- rows for settings, and a ruled footer for counts.
-
- These replaced an earlier dark treatment. A dark dialog separates
- itself from the page instantly, but it also means the app has two
- visual languages — every control inside needs a second set of tokens,
- and a settings row looks nothing like the same row on the page behind
- it. Staying on the paper keeps one language and one set of controls.
- ============================================================ */
.cf-dialog {
position: relative;
@@ -680,8 +571,6 @@
}
}
-/* Toggle. Rounded on purpose — it is one of the few controls that should
- stay a pill, because a squared switch reads as a checkbox. */
.cf-toggle {
position: relative;
flex: none;
@@ -719,8 +608,6 @@
}
}
-/* Scrim behind a dialog. Light enough that the page stays readable as
- context, which is the point of a modal rather than a new screen. */
.cf-scrim {
position: fixed;
inset: 0;
@@ -733,68 +620,22 @@
backdrop-filter: blur(2px);
}
-/* Film grain is the shared
canvas (components/Noise.tsx), mounted
- once per page surface. The CSS turbulence sheet that used to live here was
- removed so the site has exactly one grain implementation rather than two
- that drift apart. */
-
-/* ============================================================
- CanvasFlow — "paper studio" landing system
- ------------------------------------------------------------
- Design tokens + component classes consumed by app/page.tsx and the
- landing mockups in components/landing/. Those files reference
- `hex-*` class names and `--hex-*` / `--c-*` custom properties, so
- they are all declared here.
- ============================================================ */
-
:root {
/* Ink ramp */
--hex-ink: #1a1d29;
--hex-ink-soft: #5b6070;
--hex-ink-muted: #8f95a3;
- /* Two rules, two jobs.
-
- `--hex-line-strong` is the structural edge — the outline of a button,
- a field, a card, a crop mark. It is near-black so those shapes read
- as drawn boxes rather than as soft panels.
-
- `--hex-line` is the internal hairline: row dividers, sub-panel
- splits, the fine grid inside the mockups. It stays light on purpose.
- Promoting it to black as well would turn every mockup into a dense
- black lattice and bury the type inside them. */
--hex-line: rgba(26, 29, 41, 0.16);
--hex-line-strong: #1a1d29;
- /* Radius for the landing chrome. Square by default — see the
- `.hex-theme` radius reset further down. */
--hex-radius: 0px;
- /* Paper surfaces. `--hex-paper` is the pale warm page stock and
- `--hex-nav` the translucent band that floats on top of it.
-
- The stock is light on purpose. Each of the page's three backgrounds
- is an image layer painted *normally* over it, so the sheet needs
- headroom for them to read — see .hex-hero-paper / .hex-section-paper.
- `--hex-surface` is the near-white fill for insets inside cards.
-
- The nav sits a shade *below* the paper rather than above it: a band
- lighter than the sheet reads as a bright strip across the top, so
- this is a duller greige that settles into the page instead. Alpha is
- higher than the old near-white value to keep text contrast while the
- tone comes down. */
--hex-paper: #f5f3ee;
--hex-nav: rgba(231, 229, 221, 0.82);
--hex-surface: #fdfdfb;
- /* Bone: a warm off-white for outlined buttons. Kept a touch yellower
- and lighter than the paper stock so a button reads as a panel laid on
- the sheet, and clearly lighter than the dull nav band it sits in.
- Pure white would be a cold hole in a page that has no pure white
- anywhere else in its chrome. */
--hex-bone: #faf8f1;
-
- /* Data-viz palette (multi-line chart, scatter, stacked bars) */
--c-orange: #e0834a;
--c-yellow: #e3b23c;
--c-teal: #3aa793;
@@ -810,15 +651,9 @@
background: #fff;
border: 1px solid var(--hex-line-strong);
border-radius: var(--hex-radius);
- /* A hard offset rather than a blur. Once the card has a black outline
- a diffuse shadow reads as a smudge behind a drawn edge; an opaque
- step keeps the whole shape crisp and sits better on the textured
- paper. */
box-shadow: 5px 5px 0 0 rgba(26, 29, 41, 0.08);
}
- /* Border-colour helpers. These are paired with Tailwind width
- utilities (`border`, `border-b`, `border-r`, ...) in the mockups. */
.hex-line-soft {
border-color: var(--hex-line);
}
@@ -832,11 +667,6 @@
background: var(--hex-line);
}
- /* Tab in the landing mockups' ruled tab strip. Mirrors `.cf-tab` (the
- real dashboard control) but keys off `.active`, because the mockups
- are non-interactive chrome and set a class rather than the
- `aria-selected` a real tablist carries. Without this the mockup tabs
- rendered as undifferentiated plain text with no selected state. */
.hex-tab {
position: relative;
padding: 0 0 10px;
@@ -903,10 +733,6 @@
background: rgba(26, 29, 41, 0.02);
}
- /* Fine blueprint grid used behind the builder canvas.
- The background-size is essential: without it each gradient stretches
- to fill the element and paints a single line down its left edge and
- across its top instead of a repeating grid. */
.hex-grid-fine {
background-image:
linear-gradient(to right, var(--hex-ink) 1px, transparent 1px),
@@ -914,7 +740,6 @@
background-size: 24px 24px;
}
- /* Slim scrollbars inside the mockups so they read as real UI. */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(26, 29, 41, 0.16) transparent;
@@ -932,9 +757,6 @@
}
}
-/* ---- Mockup motion ---------------------------------------------- */
-
-/* Line-chart stroke draw-on. `--len` is supplied inline per path. */
@keyframes hex-draw {
from {
stroke-dashoffset: var(--len, 600);
@@ -948,7 +770,6 @@
animation: hex-draw 1.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
-/* Scatter dots pop in, staggered by inline animation-delay. */
@keyframes hex-pop {
from {
transform: scale(0);
@@ -979,15 +800,12 @@
animation: hex-grow-x 0.6s cubic-bezier(0.22, 1, 0.36, 1) backwards;
}
-/* Dashed connector flow in the routing graph (used via
- `animate-[dash_20s_linear_infinite]`). */
@keyframes dash {
to {
stroke-dashoffset: -200;
}
}
-/* CRT scanline sweep for the dark comparison matrix. */
@keyframes scanlines {
from {
background-position: 0 0;
@@ -1005,8 +823,6 @@
.hex-dot,
.hex-bar-x,
.animate-scanlines,
- /* Tailwind's own looping utilities aren't covered by its reset, so the
- live-feed indicators get listed here explicitly. */
.animate-ping,
.animate-pulse {
animation: none !important;
@@ -1016,7 +832,6 @@
}
}
-/* Small mono figure caption ("FIG.01"). */
.hex-fig {
font-family: var(--font-mono-cf), ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
@@ -1025,15 +840,7 @@
color: var(--hex-ink-muted);
}
-/* ============================================================
- Landing page chrome referenced directly by app/page.tsx
- (hex-theme / hex-paper / hex-hero-paper / hex-section-paper /
- hex-corner / hex-vignette, the button + link recipes, and the FAQ
- disclosure rows).
- ============================================================ */
-
:root {
- /* Ruler/scale strips: border colour and the diagonal hatch stroke. */
--pattern: rgba(26, 29, 41, 0.18);
--pattern-hatch: #d4d4d4;
}
@@ -1049,20 +856,6 @@
-webkit-font-smoothing: antialiased;
}
-/* ---- Boxy landing chrome -----------------------------------------
- Squares off the corner-radius utilities inside the landing theme in
- one place, instead of rewriting ~47 `rounded-*` call sites across
- page.tsx, Navbar.tsx and the mockups — and, more importantly, so the
- rule can't drift out of step as those files change.
-
- `rounded-full` is deliberately absent from this list. Avatars, status
- dots, radio marks and the toggle pills inside the mockups are meant to
- be circular; flattening those would read as a bug, not a style.
-
- The selectors are scoped under `.hex-theme` rather than global so the
- dashboard, auth and form-filling screens keep their own rounding. Two
- class selectors also outrank Tailwind's single-class utility, which is
- what lets this win without `!important`. */
.hex-theme .rounded,
.hex-theme .rounded-sm,
.hex-theme .rounded-md,
@@ -1076,18 +869,6 @@
border-radius: var(--hex-radius);
}
-/* ---- The page stock ----------------------------------------------
- A pale warm sheet with a fine tooth. The tooth is two dot grids at
- 3px and 7px, offset from one another so the beat between them reads
- as paper fibre rather than as a visible dot pattern.
-
- Nothing here is sized to the document. This element is the page root,
- so its height is the whole scroll length — any layer measured in `%`
- vertically would stretch across every section at once and lay a
- top-to-bottom tonal ramp over the page, tinting the hero one shade and
- the footer another. The page's three backgrounds are section-scoped
- layers instead (.hex-hero-paper, .hex-section-paper), which is what
- keeps them independently tunable. */
.hex-paper {
background-color: var(--hex-paper);
background-image:
@@ -1101,25 +882,6 @@
1px 2px;
}
-/* ---- The three section backgrounds ------------------------------
- The landing page runs three distinct sheets, each scoped to the
- sections that use it rather than painted page-wide:
-
- 1 · Hero — .hex-hero-paper, a tiled crack network
- 2 · Mid page — nothing; the bare stock above
- 3 · Analytics — .hex-section-paper, one large plate
-
- Both image layers paint at normal blend, *not* multiply. That is the
- whole mechanism: the source art has a white ground, so painting it
- normally lays down its dark structure and lifts the cream a step
- toward white at the same time. The hero ends up the lightest of the
- three and the bare mid-page sections the deepest, which is the tonal
- rhythm the design is after. Multiplying would drop the white ground
- out — white is the identity for multiply — and every section would
- collapse back to the same tone. */
-
-/* 1 · Hero. Tiled small enough that the cells read at page scale, and
- capped in vw so the tile doesn't dominate a narrow viewport. */
.hex-hero-paper {
position: absolute;
inset: 0;
@@ -1131,9 +893,6 @@
background-repeat: repeat;
}
-/* 3 · Analytics. A single plate scaled to cover, so the section reads
- as one continuous sheet rather than a repeat. Held much fainter than
- the hero tile because it carries far more structure. */
.hex-section-paper {
position: absolute;
inset: 0;
@@ -1157,8 +916,6 @@
}
}
-/* Crop-mark corner bracket. Callers zero out two sides inline to pick
- which corner it represents. */
.hex-corner {
position: absolute;
width: 18px;
@@ -1168,11 +925,6 @@
z-index: 2;
}
-/* Worn-paper vignette. Two radials that shade only the top and bottom
- edges of a section, so a long band of stock doesn't read as flat.
- Deliberately darkening rather than tinting with the paper colour: a
- paper-coloured wash would cover the tooth and leave a visible dead
- ring at the section edges. */
.hex-vignette::after {
content: "";
position: absolute;
@@ -1217,13 +969,7 @@
background: var(--hex-bone);
color: var(--hex-ink);
font-size: 14px;
- /* 600, not 500. On a transparent ground the animated grain sits behind
- the glyphs as well as around them, and a medium weight loses
- definition against it. */
font-weight: 600;
- /* 1.5px reads as a drawn edge and roughly matches the stem weight of
- the label beside it. A hairline looks incidental at this scale, and
- a full 2px starts to feel like a frame rather than an outline. */
border: 1.5px solid var(--hex-line-strong);
transition:
background 0.2s ease,
@@ -1253,8 +999,6 @@
display: flex;
align-items: center;
justify-content: space-between;
- /* Tighter on phones. A 24px gap plus 22px of vertical padding costs a
- lot of width and height when six of these stack on a 375px screen. */
gap: 14px;
padding: 16px 2px;
cursor: pointer;
@@ -1267,7 +1011,7 @@
padding: 22px 4px;
}
}
-/* Hide the native disclosure marker in both engines. */
+
.hex-faq-row > summary::-webkit-details-marker {
display: none;
}
diff --git a/apps/web/app/learn-more/page.tsx b/apps/web/app/learn-more/page.tsx
index 83aa5a4..da43e09 100644
--- a/apps/web/app/learn-more/page.tsx
+++ b/apps/web/app/learn-more/page.tsx
@@ -375,10 +375,6 @@ export default function LearnMorePage() {
- {/* ── Process — inverted sheet ───────────────────────────────── */}
- {/* The reference flips to `bg-foreground text-background` here. This
- app already has a dark treatment (the comparison matrix, and the
- Dark scale variants), so the flip is in the design language. */}
- {/* Crop marks, matching the hero on every other public page. */}
{
>
{/* Background 1 of 3: tiled crack network, the lightest sheet. */}
-
-
+
+
@@ -251,9 +253,6 @@ const Index = () => {
- {/* The wide mock takes the larger column and the upward
- offset, so the pair reads as staggered rather than as
- two cards on a shared baseline. */}
@@ -302,7 +301,10 @@ const Index = () => {
for when you want to click around.
-
+
Views and responses, completion rate, the hour and the day people actually reply,
and which question they gave up on. Your form becomes a real dashboard the second
answers land — no exports, no spreadsheets.
@@ -325,7 +327,6 @@ const Index = () => {
className="relative overflow-hidden border-b hex-line-soft py-16 sm:py-20 lg:py-28"
style={{ borderBottomWidth: 1 }}
>
- {/* Background 2 of 3: the bare page stock, no overlay. */}
@@ -342,7 +343,10 @@ const Index = () => {
You decide when it closes.
-
+
Publish and share the link, or hand over a QR code. Close the form with a toggle,
give it an expiry date, or cap the number of submissions. Every response is kept to
one per visitor.
@@ -364,7 +368,6 @@ const Index = () => {
className="relative overflow-hidden border-y hex-line-soft py-16 sm:py-20 lg:py-32"
style={{ borderTopWidth: 1, borderBottomWidth: 1 }}
>
- {/* Background 2 of 3: the bare page stock, no overlay. */}
@@ -443,9 +446,16 @@ const Index = () => {
d: "Email and password, or Google and GitHub if you'd rather skip another password.",
},
].map((item) => (
-
+
@@ -473,7 +483,10 @@ const Index = () => {
answered.
-
+
The short version of what teams ask us most. Want the long one?
@@ -489,10 +502,15 @@ const Index = () => {
>
-
+
0{i + 1}
- {f.q}
+
+ {f.q}
+
+
@@ -544,9 +562,6 @@ const ShareAccessMock = () => (
- {/* QR + access controls. The QR column is a fixed 104px, which leaves
- too little for the toggle rows beside it on a phone — below sm the
- two stack and the QR is capped so it doesn't blow up full-width. */}
QR code
@@ -554,13 +569,13 @@ const ShareAccessMock = () => (
className="grid grid-cols-7 gap-0.75 rounded-md border hex-line-soft bg-white p-2"
aria-hidden
>
- {/* Deterministic pattern so server and client markup match. */}
{Array.from({ length: 49 }).map((_, i) => (
))}
@@ -638,9 +653,6 @@ const FeatureStepCard = ({
- {/* The illustrations are drawn at a fixed 224x160. Scaling the whole
- group down on small screens keeps their internal proportions
- intact, which reflowing their parts would not. */}
{illustration}
@@ -652,7 +664,9 @@ const FeatureStepCard = ({
Step / {n}
-
{t}
+
+ {t}
+
{d}
@@ -717,8 +731,16 @@ const BuildIllustration = () => (
const ShareIllustration = () => (
-
-
+
+
(
-
+
FIG.{glyph}
-
{title}
-
+
+ {title}
+
+
{body}
diff --git a/apps/web/app/privacy/page.tsx b/apps/web/app/privacy/page.tsx
index ee7cbf3..6b91fdf 100644
--- a/apps/web/app/privacy/page.tsx
+++ b/apps/web/app/privacy/page.tsx
@@ -12,43 +12,16 @@ export const metadata: Metadata = {
"What CanvasFlow stores, why, how long for, and how to get it back or have it deleted — for account holders and for people answering a form.",
};
-/* ============================================================
- BEFORE PUBLISHING — these must be filled in by whoever owns
- the legal side. The copy below is written so the page still
- reads correctly while they are unset, but it is NOT complete
- as a legal document until they are:
-
- · CONTACT_EMAIL — a real address that reaches a human.
- Data-protection law generally requires a working contact
- route for rights requests.
- · LEGAL_ENTITY — the company or individual that is the
- data controller. Rendered nowhere while null.
- · SUB_PROCESSORS — the actual vendors (hosting, managed
- Postgres, email delivery, any payment processor). The
- section below describes categories truthfully but names
- no vendor, because naming one we don't use would be worse
- than naming none.
- · A governing-law / jurisdiction clause, and — if you serve
- EU/UK or California users — the specific GDPR/CCPA
- disclosures those regimes require.
-
- The data inventory itself was written against the actual
- database schema (packages/database/models) and is accurate as
- of LAST_UPDATED. If you add a column that holds personal data,
- update the tables below in the same change.
- ============================================================ */
const CONTACT_EMAIL: string | null = null;
const LEGAL_ENTITY: string | null = null;
const LAST_UPDATED = "31 July 2026";
-/** The one-paragraph version, for people who won't read the rest. */
const SUMMARY = [
"We store what you create and what people send you. We don't sell it, and we don't use it to advertise to anyone.",
"People answering your forms are not tracked with cookies, and we never record their IP address.",
"You can export every response to CSV at any time, and deleting a form or an account really deletes the data underneath it.",
];
-/** Data held about signed-in account holders. */
const ACCOUNT_DATA = [
{
what: "Identity",
@@ -166,8 +139,6 @@ export default function PrivacyPage() {
- {/* Ruled page margins, md and up only — below that there is no gutter
- for them to sit in and they would land on the copy. */}
diff --git a/apps/web/app/security/page.tsx b/apps/web/app/security/page.tsx
index f8c645b..5f3aca2 100644
--- a/apps/web/app/security/page.tsx
+++ b/apps/web/app/security/page.tsx
@@ -12,43 +12,6 @@ export const metadata: Metadata = {
"How CanvasFlow protects accounts and responses: the authorisation model, rate limits, and duplicate-submission guards — plus what we haven't built yet.",
};
-/* ============================================================
- GROUND RULES FOR THIS PAGE
-
- A security page is a trust document, so every claim below is
- one that can be pointed at a line of code:
-
- · Auth model packages/trpc/server/trpc.ts
- · Session / cookies packages/trpc/server/auth.ts
- · CORS + rate limits apps/api/src/server.ts
- · Per-form roles packages/services/form (require*)
- · Unique indexes packages/database/models/form-submission.ts
- · Cascade deletes packages/database/models/*
-
- · Response headers apps/web/next.config.js (headers())
-
- Deliberately NOT claimed, because they are not implemented as
- of LAST_UPDATED. Do not add them to this page before they are
- true in the code:
- · An access/audit log of who read which responses.
- · Any third-party certification (SOC 2, ISO 27001) or an
- independent penetration test.
- · Encryption at rest — the managed database provider very
- likely does this, but "likely" isn't a claim worth making
- on a security page. Confirm with the provider, then say so
- specifically.
- The "Not yet" section below states these openly on purpose.
-
- Two-factor authentication is intentionally out of scope for
- this product and is not listed as a gap. If that changes, the
- "Accounts and sessions" section is where it belongs.
-
- TO FILL IN:
- · SECURITY_EMAIL — a monitored address for vulnerability
- reports. Until it is set, the disclosure section routes
- people through the in-app feedback widget, which works but
- is not the convention researchers expect.
- ============================================================ */
const SECURITY_EMAIL: string | null = null;
const LAST_UPDATED = "31 July 2026";
@@ -185,8 +148,6 @@ export default function SecurityPage() {
- {/* Ruled page margins, md and up only — below that there is no gutter
- for them to sit in and they would land on the copy. */}
diff --git a/apps/web/app/terms/page.tsx b/apps/web/app/terms/page.tsx
index 9189572..3262d53 100644
--- a/apps/web/app/terms/page.tsx
+++ b/apps/web/app/terms/page.tsx
@@ -12,29 +12,6 @@ export const metadata: Metadata = {
"The agreement for using CanvasFlow: what you may build, who owns your content, and what you owe the people who answer your forms.",
};
-/* ============================================================
- BEFORE PUBLISHING — must be filled in by whoever owns the
- legal side. The copy is written to read correctly while these
- are unset, but the document is NOT complete without them:
-
- · CONTACT_EMAIL — a working address for legal and account
- notices.
- · LEGAL_ENTITY — the contracting party. Until it is set,
- nothing here names a company, which means there is no
- identified counterparty to the agreement.
- · A governing-law and dispute-resolution clause.
- · Consumer-law carve-outs for your markets. The liability
- and disclaimer sections below are drafted broadly and are
- unenforceable as written in several jurisdictions.
-
- Kept deliberately accurate to the build as of LAST_UPDATED:
- · The service is free and metered by nothing. There are no
- plans, no submission or form ceilings, and no payment
- processor — so there are no billing, renewal, refund, or
- tax terms here. Add all of the above together if paid
- tiers are ever introduced; a pricing claim without an
- enforced limit behind it is a misrepresentation.
- ============================================================ */
const CONTACT_EMAIL: string | null = null;
const LEGAL_ENTITY: string | null = null;
const LAST_UPDATED = "31 July 2026";
@@ -181,8 +158,6 @@ export default function TermsPage() {
- {/* Ruled page margins, md and up only — below that there is no gutter
- for them to sit in and they would land on the copy. */}
diff --git a/apps/web/components/DashboardNav.tsx b/apps/web/components/DashboardNav.tsx
index 62e18a4..e6aa022 100644
--- a/apps/web/components/DashboardNav.tsx
+++ b/apps/web/components/DashboardNav.tsx
@@ -11,15 +11,6 @@ import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
import { useGetMe } from "~/hooks/api/user";
import { avatarSeed, GlyphAvatar, resolvePreset } from "~/components/profile/GlyphAvatar";
-/**
- * Dashboard top navigation.
- *
- * Replaces the previous fixed sidebar. The reference puts the whole app on a
- * single centred column under one horizontal bar, which gives the forms grid
- * the full page width instead of surrendering 256px of it to a rail that only
- * ever held four links.
- */
-
const LINKS = [
{ href: "/dashboard", label: "Studio", icon: Compass },
{ href: "/dashboard/sketches", label: "Forms", icon: PencilRuler },
@@ -30,15 +21,9 @@ export default function DashboardNav() {
const pathname = usePathname();
const { openCreateFormModal } = useDashboard();
const { userInfo } = useGetLoggedInUserInfo();
- // The avatar preset lives on the user row, which the better-auth session
- // doesn't carry. Cached for 5 minutes and shared with the profile page
- // through the react-query cache, so this is not a second network trip.
const { me } = useGetMe();
const [menuOpen, setMenuOpen] = useState(false);
- // `getMe` reads the user row and wins where the two disagree: the session's
- // `name` is blank for social sign-ups, and it never reflects a rename made on
- // the profile page until the session is refreshed.
const fullName = me?.name || userInfo?.fullName || "";
const email = me?.email || userInfo?.email || "";
@@ -50,13 +35,7 @@ export default function DashboardNav() {
return (
<>
- {/* Slim black rule across the very top — the printed edge of the sheet,
- and what visually separates the app from the browser chrome. */}
-
- {/* The bar itself is near-white rather than the page grey. In the
- reference it reads as a separate strip laid over the sheet, and
- matching the page tone would lose that edge. */}
- {/* Way into the profile, carrying the same generated mark the
- profile page shows. Initials were the obvious thing here, but
- the session's `name` comes back empty for social sign-ups,
- which left this rendering a bare "?".
-
- The avatar draws its own edge, so the wrapper contributes only
- the active-state ring. */}
- {/* Mobile: the primary action stays reachable, everything else folds
- into the panel. */}
{
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
- // Mirrors the server's minimums so the caller gets told before the round
- // trip. The server still enforces them — this is convenience, not a check.
if (subject.trim().length < 3) {
toast.error("Please add a subject (at least 3 characters)");
return;
@@ -86,14 +84,6 @@ const FeedbackWidget = () => {
return;
}
- // Goes over tRPC like every other write in the app. This previously POSTed
- // to `${API}/api/complaints`, which was never implemented — so the widget
- // has been failing silently into a toast.
- //
- // `userId`, `userEmail`, `status` and `priority` used to be in this body.
- // They're gone: identity is read from the session on the server (a client
- // could otherwise file a report as any address it liked), and status and
- // priority are triage fields the reporter has no business setting.
try {
await submitFeedbackAsync({
type,
@@ -108,8 +98,6 @@ const FeedbackWidget = () => {
setType("feedback");
setIsOpen(false);
} catch (err) {
- // Surface the server's message where there is one: it carries the real
- // reason (too short, over the hourly cap) instead of a generic failure.
toast.error(
err instanceof Error && err.message ? err.message : "Failed to submit. Please try again.",
);
@@ -118,17 +106,8 @@ const FeedbackWidget = () => {
const canSubmit = subject.trim().length >= 3 && message.trim().length >= 10 && !loading;
- // Hidden while someone is filling out a form. `/forms/*` is the respondent's
- // view of a form somebody else built — they aren't a CanvasFlow user, and a
- // floating "report a bug in CanvasFlow" button there is both confusing and a
- // distraction from the form they came to answer.
if (pathname?.startsWith("/forms/")) return null;
- // Hidden in the builder too. It's a full-bleed workspace whose own canvas
- // controls (zoom, fit, lock) sit in the bottom-left and whose inspector runs
- // to the bottom-right edge, so a floating button there covers the tool it
- // overlaps. `/dashboard/sketches` (the list) keeps the widget; only the
- // per-form builder loses it.
if (/^\/dashboard\/sketches\/[^/]+/.test(pathname ?? "")) return null;
return (
diff --git a/apps/web/components/Footer.tsx b/apps/web/components/Footer.tsx
index 7a35acf..8644309 100644
--- a/apps/web/components/Footer.tsx
+++ b/apps/web/components/Footer.tsx
@@ -13,8 +13,6 @@ const Footer = () => {
};
return (
- // Colour comes from the accent token rather than a literal, so the footer
- // and the app's primary actions can never drift apart.
{/* Links Section */}
diff --git a/apps/web/components/Navbar.tsx b/apps/web/components/Navbar.tsx
index b436029..d0c1225 100644
--- a/apps/web/components/Navbar.tsx
+++ b/apps/web/components/Navbar.tsx
@@ -7,11 +7,6 @@ import { ChevronDown, Menu, X } from "lucide-react";
import { cn } from "~/lib/utils";
import { useGetLoggedInUserInfo } from "~/hooks/api/auth";
-/* ── Nav content ───────────────────────────────────────────────────────
- Held as data rather than inline JSX so the desktop dropdowns and the
- mobile panel render from one source. Inlining it twice is how the two
- drift apart. */
-
type NavLinkItem = { title: string; desc: string; href: string };
const PLATFORM_GROUPS: { heading: string; items: NavLinkItem[] }[] = [
@@ -193,10 +188,6 @@ const SimpleDropdown = ({ items }: { items: NavLinkItem[] }) => (
const PROMO_NOISE = `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`;
const PlatformMegaMenu = () => (
- /* Capped against the viewport as well as at a fixed width. At exactly
- 1024px — where this menu first appears — a hard 700px panel anchored
- under "Platform" runs past the right edge once the page gutter is
- accounted for. */
{PLATFORM_GROUPS.map((group) => (
@@ -238,11 +229,6 @@ const PlatformMegaMenu = () => (
);
-/* ── Mobile panel ──────────────────────────────────────────────────────
- The desktop dropdowns open on hover, which has no equivalent on touch,
- so small screens get a disclosure list instead of the same menus
- crammed into a narrower box. */
-
const MobileSection = ({
label,
items,
@@ -291,7 +277,6 @@ const MobileSection = ({
);
};
-/** Scallop shape for the strip beneath the nav bar, used as a mask. */
const WAVE_MASK = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 10'%3E%3Cpath d='M0,0 C5,10 15,10 20,0 Z' fill='%23000'/%3E%3C/svg%3E")`;
const Navbar = () => {
@@ -299,8 +284,6 @@ const Navbar = () => {
const [menuOpen, setMenuOpen] = useState(false);
const closeMenu = () => setMenuOpen(false);
- // Reaching a desktop width while the panel is open would otherwise leave
- // it mounted underneath the restored desktop nav.
useEffect(() => {
if (!menuOpen) return;
const mq = window.matchMedia("(min-width: 1024px)");
@@ -313,9 +296,6 @@ const Navbar = () => {
return (
- {/* Blur without a saturation boost. `backdrop-saturate` lifts the
- chroma of whatever scrolls under the bar, which works against
- the muted band the design wants. */}
{
}}
>
- {/* Left Navigation — desktop only. The hover dropdowns it hosts
- have no touch equivalent, so below lg the whole group is
- replaced by the disclosure panel. */}
@@ -345,8 +322,6 @@ const Navbar = () => {
- {/* Logo. Centred on desktop between the two nav groups; flush
- left on mobile, where the hamburger takes the right. */}
{
- {/* Hamburger — mobile only. The CTA lives inside the panel rather
- than beside this button: at 320px a 117px outlined button, the
- logo and the toggle do not fit on one line. */}
setMenuOpen((v) => !v)}
@@ -441,11 +413,6 @@ const Navbar = () => {
)}
- {/* Wave bottom decoration */}
- {/* Scalloped edge under the bar. The wave is applied as a mask and
- filled with the nav token rather than baked into the SVG, so it
- tracks `--hex-nav` instead of drifting out of step with it the
- way a hardcoded fill does. */}
` rather than restating them — that keeps the texture
- * identical on the landing, the auth screens, the dashboard and the public form
- * instead of drifting per page.
- *
- * Two guards matter because this now runs on every route rather than just the
- * marketing page:
- *
- * - `prefers-reduced-motion` draws a single static frame and never loops. A
- * full-viewport repaint several times a second is exactly the kind of
- * ambient movement that setting exists to suppress.
- * - A hidden tab stops the loop. Without this the canvas keeps repainting
- * behind other windows, which is pure battery cost for something nobody can
- * see. The listener restarts it on return.
- */
const Noise: React.FC = ({ patternRefreshInterval = 4, patternAlpha = 16 }) => {
const grainRef = useRef(null);
@@ -40,9 +17,6 @@ const Noise: React.FC = ({ patternRefreshInterval = 4, patternAlpha
const ctx = canvas.getContext("2d", { alpha: true });
if (!ctx) return;
- // The canvas is a fixed 1024px buffer stretched over the viewport by CSS.
- // Grain has no "correct" scale, so upscaling a fixed buffer is cheaper than
- // reallocating an image the size of the window on every resize.
const canvasSize = 1024;
let frame = 0;
let animationId: number | null = null;
diff --git a/apps/web/components/NotFoundPanel.tsx b/apps/web/components/NotFoundPanel.tsx
index d2044a6..702d8dc 100644
--- a/apps/web/components/NotFoundPanel.tsx
+++ b/apps/web/components/NotFoundPanel.tsx
@@ -5,35 +5,6 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ArrowLeft, TriangleAlert } from "lucide-react";
-/* ── Adapted from the supplied reference ───────────────────────────────
- Structure kept: the badge on the panel corner, the oversized numeral with
- a rule under it, the "probable cause" box, the three-up data grid, the
- two big actions, and the mono meta line at the bottom.
-
- Changed on the way in:
-
- 1 · Chrome. The reference is Neo-Brutalist and leans on `shadow-brutal`
- utilities that don't exist here. Restated in the `hex-*` paper-studio
- language used by /about, /learn-more and /docs.
-
- 2 · Plumbing. `react-router-dom` useLocation/Link → `usePathname` and
- `next/link`; `framer-motion` → `motion` (this repo's package). The
- glitch is done with a state swap rather than a motion component, so
- it can be skipped outright under reduced-motion.
-
- 3 · Voice. The reference is shouting spy-thriller in all-caps — "SYSTEM
- BREACH", "REBOOT REALITY", "© DEEP DATA SQUAD". This product's voice
- is dry and lowercase-ish, so the jokes stay and the volume comes down.
-
- 4 · Usefulness. "SECURITY CLEARANCE: ZERO / VOID" is a gag with no
- information in it. A 404 is a dead end, so the panels and the link row
- carry the actual routes of this app instead, which is the one thing a
- lost visitor needs.
-
- 5 · "Reboot reality" reloaded the page, which on a 404 just re-renders the
- 404. It's `router.back()` now, which does something. */
-
-/** Dry, and each one an actual reason a URL here would miss. */
const CAUSES = [
"The link was mistyped, or it pointed somewhere that has since moved.",
"An old bookmark, from before something got renamed.",
@@ -49,10 +20,6 @@ export function NotFoundPanel() {
const [glitch, setGlitch] = useState("404");
- /* `/_not-found` is prerendered at build time, so anything derived from the
- real URL — or from Math.random — has to land after mount or the static
- HTML and the first client render disagree and React logs a hydration
- mismatch. Both start empty/fixed and fill in from this effect. */
const [mounted, setMounted] = useState(false);
const [cause, setCause] = useState(CAUSES[0]!);
@@ -62,7 +29,6 @@ export function NotFoundPanel() {
}, []);
useEffect(() => {
- // Anyone who has asked for less motion gets a numeral that just sits there.
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduced.matches) return;
@@ -89,8 +55,6 @@ export function NotFoundPanel() {
{/* ── Main panel ─────────────────────────────────────────────── */}
- {/* Corner badge. Inset on phones so it can't hang off the viewport
- the way a symmetric negative offset would. */}
diff --git a/apps/web/components/analytics/DeviceBreakdown.tsx b/apps/web/components/analytics/DeviceBreakdown.tsx
index 91a66be..02ee6ec 100644
--- a/apps/web/components/analytics/DeviceBreakdown.tsx
+++ b/apps/web/components/analytics/DeviceBreakdown.tsx
@@ -13,14 +13,6 @@ interface DeviceBreakdownProps {
deviceData: DeviceData[];
}
-/**
- * Device split of submissions.
- *
- * The total is summed from `deviceData` rather than taken from the response
- * count, because submissions recorded before device tracking existed carry no
- * device and are excluded upstream. Totalling locally keeps the slices and the
- * percentages consistent with each other.
- */
export function DeviceBreakdown({ deviceData }: DeviceBreakdownProps) {
const total = deviceData.reduce((sum, d) => sum + d.value, 0);
diff --git a/apps/web/components/analytics/EngagementStats.tsx b/apps/web/components/analytics/EngagementStats.tsx
index 99157a8..9cef8de 100644
--- a/apps/web/components/analytics/EngagementStats.tsx
+++ b/apps/web/components/analytics/EngagementStats.tsx
@@ -29,10 +29,6 @@ const formatLag = (minutes: number | null) => {
return `${(h / 24).toFixed(1)}d`;
};
-/**
- * Four engagement figures the Pro payload already returned but nothing rendered
- * after the standalone detailed page was removed.
- */
export function EngagementStats({
avgTimeSpentMs,
medianResponseTime,
diff --git a/apps/web/components/analytics/FieldDropoff.tsx b/apps/web/components/analytics/FieldDropoff.tsx
index ad15761..7f3a686 100644
--- a/apps/web/components/analytics/FieldDropoff.tsx
+++ b/apps/web/components/analytics/FieldDropoff.tsx
@@ -34,7 +34,6 @@ interface FieldDropoffProps {
submissions: Submission[];
}
-/** Render any stored answer shape as readable text. */
const formatValue = (value: unknown): string => {
if (value === null || value === undefined || value === "") return "";
if (Array.isArray(value)) return value.map((v) => String(v)).join(", ");
@@ -43,18 +42,9 @@ const formatValue = (value: unknown): string => {
return String(value);
};
-/**
- * Per-field completion, with a drill-down into the answers.
- *
- * The rates come from the Pro payload, but the answers behind them come from
- * `submissions`, which the analytics page already has in memory — so opening a
- * field costs no extra request.
- */
export function FieldDropoff({ fieldCompletionRates, fields, submissions }: FieldDropoffProps) {
const [openFieldId, setOpenFieldId] = useState(null);
- // Index answers by field once, rather than scanning every submission each
- // time a row is opened.
const answersByField = useMemo(() => {
const map = new Map();
for (const sub of submissions) {
@@ -70,8 +60,6 @@ export function FieldDropoff({ fieldCompletionRates, fields, submissions }: Fiel
}, [submissions]);
const rows = useMemo(() => {
- // Prefer the server's field order; fall back to the rates list for fields
- // the form no longer has.
const byId = new Map(fieldCompletionRates.map((r) => [r.fieldId, r]));
const ordered: (FieldRate & { type?: string })[] = [];
for (const f of fields) {
@@ -142,8 +130,6 @@ export function FieldDropoff({ fieldCompletionRates, fields, submissions }: Fiel
{row.fieldLabel}
- {/* The bar is the quantity; the number beside it is the
- same value for anyone who can't compare lengths. */}
{
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -222,13 +207,10 @@ function FieldAnswersDialog({
return answers.filter((a) => a.text.toLowerCase().includes(q));
}, [answers, query]);
- // Repeated answers are the useful signal on choice fields, so tally them.
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]);
- // Only worth showing when answers actually repeat — on a free-text field
- // every value is unique and a tally is just the list again.
return list.length > 0 && list.length < answers.length ? list.slice(0, 6) : [];
}, [answers]);
diff --git a/apps/web/components/analytics/MetricsGrid.tsx b/apps/web/components/analytics/MetricsGrid.tsx
index 858b3a2..684caee 100644
--- a/apps/web/components/analytics/MetricsGrid.tsx
+++ b/apps/web/components/analytics/MetricsGrid.tsx
@@ -7,19 +7,6 @@ interface MetricsGridProps {
avgPerDay: number;
}
-/**
- * KPI row, laid out like the analytics mock: a large figure, its label, and a
- * mono supporting line.
- *
- * The supporting line carries a real derived number rather than a
- * "↑ 9.7% vs last week" style delta — the summary analytics payload has no
- * previous-period figures to compare against, and inventing one would be
- * worse than omitting it.
- *
- * This row used to also show total views and a completion rate. Both were
- * derived from page-view tracking, which no longer exists — peak day and
- * weekly average live in StatsRow, so they're not repeated here.
- */
export function MetricsGrid({ totalResponses, avgPerDay }: MetricsGridProps) {
const stats = [
{
diff --git a/apps/web/components/analytics/PeriodComparison.tsx b/apps/web/components/analytics/PeriodComparison.tsx
index f095920..187bc1c 100644
--- a/apps/web/components/analytics/PeriodComparison.tsx
+++ b/apps/web/components/analytics/PeriodComparison.tsx
@@ -10,14 +10,6 @@ interface PeriodComparisonProps {
trend90d: number;
}
-/**
- * Responses per 30-day window over the last quarter.
- *
- * The server returns cumulative totals (`trend60d` includes the last 30 days),
- * so the older buckets are differences. Charting the cumulative figures
- * directly would show three bars that can only ever grow, which reads as
- * upward momentum even for a form whose traffic is collapsing.
- */
export function PeriodComparison({ trend30d, trend60d, trend90d }: PeriodComparisonProps) {
const buckets = [
{ label: "Last 30d", value: trend30d, colour: SERIES[0] },
diff --git a/apps/web/components/analytics/QuestionDistribution.tsx b/apps/web/components/analytics/QuestionDistribution.tsx
index 9f6937d..1d68bbc 100644
--- a/apps/web/components/analytics/QuestionDistribution.tsx
+++ b/apps/web/components/analytics/QuestionDistribution.tsx
@@ -177,13 +177,6 @@ function TextSamples({ samples, totalAnswered }: { samples: string[]; totalAnswe
);
}
-/**
- * One-line summary shown while a question is collapsed.
- *
- * The whole point of collapsing is to shorten the section, so a closed row
- * still has to say something useful — otherwise it is a list of labels and you
- * have to open every one to find the interesting question.
- */
function collapsedSummary(q: QuestionItem): string | null {
if (q.totalAnswered === 0) return "No answers yet";
if (CHOICE_TYPES.includes(q.fieldType) && q.optionCounts?.length) {
@@ -257,14 +250,6 @@ function QuestionBody({ q }: { q: QuestionItem }) {
);
}
-/**
- * Question breakdown as a single-open accordion.
- *
- * Rendering every question's full distribution at once made this the longest
- * block on the page — a ten-field form pushed everything below it off screen.
- * Collapsed rows keep the whole form scannable in one view, and only one opens
- * at a time so the section's height stays roughly constant.
- */
export function QuestionDistribution({ questionDistribution }: QuestionDistributionProps) {
const [openId, setOpenId] = useState(null);
diff --git a/apps/web/components/analytics/SubmissionDetailModal.tsx b/apps/web/components/analytics/SubmissionDetailModal.tsx
index ec4f941..0add771 100644
--- a/apps/web/components/analytics/SubmissionDetailModal.tsx
+++ b/apps/web/components/analytics/SubmissionDetailModal.tsx
@@ -1,7 +1,10 @@
"use client";
import React from "react";
-import { X } from "lucide-react";
+import { Download, X } from "lucide-react";
+
+import { ModalOverlay } from "~/components/ui/ModalOverlay";
+import { downloadUrlFor, formatBytes } from "~/lib/upload";
interface SubmissionValue {
formFieldId: string;
@@ -28,12 +31,6 @@ interface SubmissionDetailModalProps {
onClose: () => void;
}
-/**
- * Detail modal for a single submission. Pulled into its own file so the
- * parent SubmissionsTable can lazy-load it via `next/dynamic` — keeps it
- * out of the initial route bundle since most users browse the list
- * without opening any row.
- */
export function SubmissionDetailModal({
submission,
form,
@@ -43,8 +40,13 @@ export function SubmissionDetailModal({
const respondent = getRespondentDetails(submission);
return (
-
-
+
+
{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 (answer?.value !== undefined && answer?.value !== null && answer?.value !== "") {
+ if (hasValue) {
if (Array.isArray(answer.value)) {
displayVal = answer.value.join(", ");
} else if (typeof answer.value === "boolean") {
@@ -97,6 +169,6 @@ export function SubmissionDetailModal({
-
+
);
}
diff --git a/apps/web/components/analytics/SubmissionsTable.tsx b/apps/web/components/analytics/SubmissionsTable.tsx
index 327b2df..f2f70f9 100644
--- a/apps/web/components/analytics/SubmissionsTable.tsx
+++ b/apps/web/components/analytics/SubmissionsTable.tsx
@@ -31,20 +31,17 @@ interface SubmissionsTableProps {
setViewingSubmission: (sub: Submission | null) => void;
viewingSubmission: Submission | null;
form: { fields: FormField[] } | null | undefined;
- // Pagination state from useGetSubmissions's underlying useInfiniteQuery
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
}
-// Detail modal is only rendered when a row is clicked. Lazy-load it so it
-// stays out of the initial bundle.
const SubmissionDetailModal = dynamic(
() => import("./SubmissionDetailModal").then((m) => m.SubmissionDetailModal),
{ ssr: false },
);
-const ROW_HEIGHT = 64; // px — fixed-height rows make virtualization simple
+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";
@@ -60,25 +57,18 @@ export function SubmissionsTable({
isFetchingNextPage,
fetchNextPage,
}: SubmissionsTableProps) {
- // Scroll container for the virtualizer
const scrollRef = useRef
(null);
const rowVirtualizer = useVirtualizer({
count: filteredSubmissions.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => ROW_HEIGHT,
- // Render a few rows above/below the viewport so fast scrolls don't
- // flash empty space.
overscan: 6,
});
const virtualItems = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
- // Auto-load the next page when the user scrolls within ~3 rows of the
- // bottom. Pairs with the visible "Load older" button so users can pull
- // manually if the auto-trigger missed (e.g. they jumped to the bottom
- // with End key before any rows rendered).
React.useEffect(() => {
const last = virtualItems[virtualItems.length - 1];
if (!last) return;
@@ -87,11 +77,8 @@ export function SubmissionsTable({
}
}, [virtualItems, hasNextPage, isFetchingNextPage, filteredSubmissions.length, fetchNextPage]);
- // Cap the scroll area: tall enough to feel like a real table, capped so
- // it doesn't push the page off the bottom on small viewports.
const scrollHeight = useMemo(() => {
if (filteredSubmissions.length === 0) return 0;
- // ~6 rows visible by default; clamp to 640px max.
return Math.min(filteredSubmissions.length * ROW_HEIGHT, 640);
}, [filteredSubmissions.length]);
@@ -129,10 +116,7 @@ export function SubmissionsTable({
) : (
- {/* min-width keeps the header + virtualized rows aligned even
- when the viewport is narrow; horizontal scroll on phones */}
- {/* Header — outside the virtualized scroll area, always visible */}
Respondent
@@ -147,9 +131,6 @@ export function SubmissionsTable({
Action
-
- {/* Virtualised body — only rows currently in/near the
- viewport are rendered. Scales to thousands of rows. */}
- {/* Pagination footer — manual fallback for the auto-trigger
- above. Surfaces loading state when more rows are coming. */}
{(hasNextPage || isFetchingNextPage) && (
{isFetchingNextPage ? (
@@ -252,7 +231,6 @@ export function SubmissionsTable({
- {/* Submission detail modal — code-split, only loaded when opened */}
{viewingSubmission && form && (
SERIES[i % SERIES.length] as string;
-/** Semantic colours for rates and deltas. */
export const SEMANTIC = {
good: "#3aa793",
warn: "#e3b23c",
@@ -34,17 +17,12 @@ export const SEMANTIC = {
accent: "#2d5cf6",
} as const;
-/**
- * Completion-rate colour. Banded rather than a continuous gradient so two
- * fields in the same band read as comparable at a glance.
- */
export const rateColor = (rate: number) => {
if (rate >= 75) return SEMANTIC.good;
if (rate >= 40) return SEMANTIC.warn;
return SEMANTIC.bad;
};
-/** Neutral chrome for axes, grids and tooltips, matching the cool grey theme. */
export const CHROME = {
axis: "#5b6070",
grid: "rgba(26,29,41,0.10)",
@@ -52,13 +30,6 @@ export const CHROME = {
surface: "#e8e8e8",
} as const;
-/**
- * Device breakdown keeps a fixed mapping so a device is always one colour.
- *
- * Declared with literal keys rather than `Record`: the project
- * builds with `noUncheckedIndexedAccess`, so a string-indexed record would make
- * every lookup `string | undefined` and force a non-null assertion at each use.
- */
export const DEVICE_COLORS = {
Desktop: "#2d5cf6",
Mobile: "#3aa793",
diff --git a/apps/web/components/auth/AuthFields.tsx b/apps/web/components/auth/AuthFields.tsx
index de23f3d..5475106 100644
--- a/apps/web/components/auth/AuthFields.tsx
+++ b/apps/web/components/auth/AuthFields.tsx
@@ -4,15 +4,6 @@ import React, { useState } from "react";
import { Eye, EyeOff } from "lucide-react";
import type { UseFormRegisterReturn } from "react-hook-form";
-/**
- * Form primitives shared by /signIn and /signUp.
- *
- * Both routes render the same field, label and social row, so they live here
- * rather than being copy-pasted per page — which is how the two screens
- * previously drifted apart.
- */
-
-/** Mono uppercase caption above each input. */
export const FieldLabel = ({
children,
htmlFor,
@@ -29,10 +20,6 @@ export const FieldLabel = ({
);
-/* A single set of input classes so the text, email and password fields cannot
- fall out of step. Square with a black edge, matching the landing chrome; the
- focus state is a hard offset shadow rather than a glow ring for the same
- reason the cards use one. */
const INPUT_CLASS =
"w-full rounded-none border hex-line-strong bg-white px-4 py-3 text-[15px] outline-none transition-shadow placeholder:text-(--hex-ink-muted) focus:shadow-[3px_3px_0_0_rgba(26,29,41,0.14)]";
@@ -69,7 +56,6 @@ export const Field = ({
);
-/** Password field with a reveal toggle. */
export const PasswordField = ({
id,
label,
diff --git a/apps/web/components/builder/BuilderHeader.tsx b/apps/web/components/builder/BuilderHeader.tsx
index 23081e9..6e4997a 100644
--- a/apps/web/components/builder/BuilderHeader.tsx
+++ b/apps/web/components/builder/BuilderHeader.tsx
@@ -150,9 +150,6 @@ export function BuilderHeader({
{/* right: actions */}
- {/* View switcher. Desktop only: the canvas needs pointer
- drag-and-drop plus room for three panes, so below lg the list is
- the only surface and offering a choice would be a dead end. */}
- {/* Preview / Share / Delete — visible on sm+ */}
- {/* `?preview=1` makes the public form fillable and submittable without
- writing anything: no response, no draft, no answer tracking. Without
- it, an author testing their own form would record a response to it
- and burn their one-per-visitor slot. */}
;
currentSegmentId?: string | null;
onChangeSegment?: (segmentId: string | null) => void;
- /** One plain-English line per branch, in evaluation order. */
ruleSummaries?: string[];
- /** Branches that are saved but incomplete, so the rail can say so. */
incompleteRuleCount?: number;
onEditBranching?: () => void;
isLastInSegment?: boolean;
@@ -49,9 +34,6 @@ export interface FieldInspectorProps extends FieldFlowProps {
/* ─── helpers ────────────────────────────────────────────────────────── */
-// SELECT/CHECKBOX `choices` live alongside other config (like `position`) on
-// the field's options. Merge the new choices into the existing object so we
-// don't clobber position or any future config when editing the choice list.
function mergeChoices(currentOptions: any, nextChoices: string[]): Record
{
const base =
currentOptions && typeof currentOptions === "object" && !Array.isArray(currentOptions)
@@ -60,6 +42,31 @@ function mergeChoices(currentOptions: any, nextChoices: string[]): Record
+ str
+ .split(",")
+ .map((s) => s.trim().toLowerCase())
+ .filter(Boolean)
+ .sort()
+ .join(",");
+
/* ─── primitives ─────────────────────────────────────────────────────── */
function Label({ children }: { children: React.ReactNode }) {
@@ -95,12 +102,6 @@ function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void
);
}
-/* ─── shared body ────────────────────────────────────────────────────────
- *
- * Form inputs for a selected field. Used both by the desktop aside
- * `FieldInspector` and the mobile `MobileFieldEditorSheet`. Caller is
- * responsible for ensuring `selectedField` is non-null before rendering.
- */
export function FieldInspectorBody({
selectedField,
label,
@@ -236,22 +237,22 @@ export function FieldInspectorBody({
Max stars
-
+ onChange={(val) =>
updateLocal(selectedField.id, {
options: {
...((selectedField.options as any) || {}),
- max: parseInt(e.target.value),
+ max: parseInt(val, 10),
},
})
}
- className="cf-input h-9 cursor-pointer px-3 text-[13px]"
- >
- 3 — Small
- 5 — Standard
- 10 — Detailed
-
+ options={[
+ { value: 3, label: "3 — Small" },
+ { value: 5, label: "5 — Standard" },
+ { value: 10, label: "10 — Detailed" },
+ ]}
+ />
)}
@@ -387,26 +388,109 @@ export function FieldInspectorBody({
)}
+ {selectedField.type === "FILE_UPLOAD" && (
+
+
+
Files per response
+
+ updateLocal(selectedField.id, {
+ options: {
+ ...((selectedField.options as any) || {}),
+ maxFiles: parseInt(val, 10),
+ },
+ })
+ }
+ options={[1, 2, 3, 4, 5, 8, 10, 15, 20].map((n) => ({
+ value: n,
+ label: n === 1 ? "1 — Single file" : `${n} files`,
+ }))}
+ />
+
+ Respondents upload one at a time, so each file gets its own progress and retry.
+
+
+
+
+
Max size per file
+
{
+ const next = { ...((selectedField.options as any) || {}) };
+ if (val) next.maxMb = parseInt(val, 10);
+ else delete next.maxMb;
+ updateLocal(selectedField.id, { options: next });
+ }}
+ options={[
+ { value: "", label: "Server limit" },
+ ...[1, 2, 5, 10, 25, 50, 100].map((n) => ({
+ value: n,
+ label: `${n} MB`,
+ })),
+ ]}
+ />
+
+ Clamped by the server's own cap, so this can only be stricter.
+
+
+
+
+
Accepted types
+ {(() => {
+ const currentAcceptArr = (selectedField.options as any)?.accept || [];
+ const currentAcceptStr = Array.isArray(currentAcceptArr)
+ ? currentAcceptArr.join(", ")
+ : "";
+
+ const matchedPreset = PRESET_FILE_TYPES.find(
+ (preset) => normalizeForMatch(preset.value) === normalizeForMatch(currentAcceptStr),
+ );
+
+ const selectValue = matchedPreset ? matchedPreset.value : "";
+
+ return (
+
{
+ const next = { ...((selectedField.options as any) || {}) };
+ if (val) {
+ next.accept = String(val)
+ .split(",")
+ .map((s) => s.trim());
+ } else {
+ delete next.accept;
+ }
+ updateLocal(selectedField.id, { options: next });
+ }}
+ options={PRESET_FILE_TYPES}
+ />
+ );
+ })()}
+
+ Narrow down accepted file types. Leave as Any allowed type to accept everything the
+ server allows.
+
+
+
+ )}
+
{/* ── flow: which page this question is on, and where answers lead ── */}
{segmentOptions && segmentOptions.length > 0 && onChangeSegment && (
This question belongs to
- onChangeSegment(e.target.value || null)}
- className="cf-input h-9 cursor-pointer px-3 text-[13px]"
- >
- {/* Only offered while it's the actual state. Once a form is
- segmented, "no segment" isn't a place an author means to put
- a question — it would render ahead of segment 1. */}
- {!currentSegmentId && Not in a segment }
- {segmentOptions.map((option) => (
-
- {option.label}
-
- ))}
-
+ onChange={(val) => onChangeSegment(val || null)}
+ options={[
+ ...(!currentSegmentId ? [{ value: "", label: "Not in a segment" }] : []),
+ ...segmentOptions.map((option) => ({
+ value: option.id,
+ label: option.label,
+ })),
+ ]}
+ />
)}
diff --git a/apps/web/components/builder/FieldOutline.tsx b/apps/web/components/builder/FieldOutline.tsx
index 2198109..a3f5f12 100644
--- a/apps/web/components/builder/FieldOutline.tsx
+++ b/apps/web/components/builder/FieldOutline.tsx
@@ -15,27 +15,10 @@ interface FieldOutlineProps {
fields: FieldLike[];
onTapField: (id: string) => void;
onMove: (id: string, direction: "up" | "down") => void;
- /** Which field the details pane is currently showing, if any. */
selectedId?: string | null;
- /**
- * Add affordance. Omitted on surfaces that already have a field palette
- * beside the outline — a second "Add field" button next to a full palette
- * is just a slower way to do the same thing.
- */
onAdd?: () => void;
}
-/**
- * Ordered outline of a form's fields: what the sequence is, and how to
- * change it. Reordering is done with up/down buttons rather than drag, which
- * works the same under a mouse and under a thumb, and doesn't fight vertical
- * scrolling on a phone.
- *
- * Used by two surfaces, hence the neutral name: the phone editor (where
- * tapping a card opens a bottom sheet) and the desktop outline builder (where
- * it sits between the palette and the details pane, and tapping a card just
- * selects it).
- */
export function FieldOutline({ fields, onTapField, onMove, selectedId, onAdd }: FieldOutlineProps) {
return (
@@ -48,10 +31,6 @@ export function FieldOutline({ fields, onTapField, onMove, selectedId, onAdd }:
- {/* Capped and centred: on a wide screen the middle pane runs past 900px,
- and a card stretched that far leaves its label and its edit
- affordance at opposite ends of the row. The cap never binds on a
- phone. Bottom padding clears the sticky CTA where there is one. */}
- {/* Position in the sequence — the thing this surface exists to show. */}
void;
- /**
- * Click-to-append. Supplied by the outline surface, where dragging has
- * nowhere meaningful to land — the sequence is the layout, so a new field
- * goes on the end and gets moved from there. When omitted the palette is
- * drag-only, which is what the canvas wants.
- */
onPick?: (type: string) => void;
}
@@ -21,6 +15,7 @@ const CATEGORIES = [
{ label: "Choice", types: ["SELECT", "CHECKBOX"] },
{ label: "Interactive", types: ["RATING", "TOGGLE"] },
{ label: "Date & time", types: ["DATE", "TIME"] },
+ { label: "Files", types: ["FILE_UPLOAD"] },
];
export function FieldSidebar({ onDragStart, onPick }: FieldSidebarProps) {
@@ -39,9 +34,6 @@ export function FieldSidebar({ onDragStart, onPick }: FieldSidebarProps) {
className="flex w-64 shrink-0 flex-col border-r bg-(--cf-cream-2)"
style={{ borderRightColor: "var(--cf-line-strong)" }}
>
- {/* Chrome row. Fixed at h-10 to match the canvas and inspector rows so
- the top rule runs unbroken across all three panes; the search gets
- its own band below rather than growing this one out of alignment. */}
Fields
{filtered && (
@@ -121,9 +113,6 @@ function FieldItem({
}) {
const Icon = field.icon;
- // A real
when clicking is the interaction, so the palette is
- // keyboard reachable on the outline surface. Still draggable either way:
- // the canvas needs the drag, and it costs nothing here.
const Tag = onPick ? "button" : "div";
return (
diff --git a/apps/web/components/builder/FormFieldNode.tsx b/apps/web/components/builder/FormFieldNode.tsx
index bb2b50a..5e3c628 100644
--- a/apps/web/components/builder/FormFieldNode.tsx
+++ b/apps/web/components/builder/FormFieldNode.tsx
@@ -11,6 +11,7 @@ import {
Link2,
List,
Mail,
+ Paperclip,
Phone,
Star,
ToggleLeft,
@@ -31,6 +32,7 @@ export const AVAILABLE_FIELDS = [
{ type: "DATE", label: "Date", icon: Calendar, description: "Calendar selection" },
{ type: "TIME", label: "Time", icon: Clock, description: "Time selection" },
{ type: "TOGGLE", label: "Toggle", icon: ToggleLeft, description: "Yes / no switch" },
+ { type: "FILE_UPLOAD", label: "File upload", icon: Paperclip, description: "Attach a document" },
];
export const getFieldIcon = (type: string) => {
@@ -52,17 +54,12 @@ export const FormFieldNode = ({ data, selected }: { data: any; selected: boolean
return (
- {/* Titlebar. Tinted and ruled so the node reads as a drawn card
- rather than one undivided box. */}
0 ? accept.join(", ") : "Any allowed type";
+
+ return (
+
+
+
+
{field.placeholder || "Choose a file"}
+
+
+ {[kinds, limit].filter(Boolean).join(" · ")}
+
+
+ );
+ }
+
// text/textarea/email/number/phone/url
return (
diff --git a/apps/web/components/builder/FormSettingsDialog.tsx b/apps/web/components/builder/FormSettingsDialog.tsx
index 285da19..4ad5d75 100644
--- a/apps/web/components/builder/FormSettingsDialog.tsx
+++ b/apps/web/components/builder/FormSettingsDialog.tsx
@@ -9,8 +9,6 @@ import { canRenderOnOnePage } from "~/lib/form-flow";
type QuestionLayout = "AUTO" | "ONE_PER_PAGE" | "SEGMENT_PER_PAGE" | "ALL_AT_ONCE";
-/** Matches the service's cap, so the author is stopped here rather than by a
- * validation error after pressing Save. */
const MAX_DOMAINS = 20;
const MAX_THANK_YOU = 2000;
@@ -29,18 +27,11 @@ interface FormSettingsDialogProps {
allowedEmailDomains?: string[] | null;
thankYouMessage?: string | null;
} | null;
- /** Drives the sentence explaining what AUTO resolves to for this form. */
segmentCount?: number;
- /** Active branching rules. A form that branches can't be shown on one page. */
ruleCount?: number;
onClose: () => void;
}
-/**
- * The four layouts, worded from the respondent's side rather than the
- * schema's — an author is choosing what filling the form feels like, not
- * naming an enum.
- */
const LAYOUT_CHOICES: Array<{ value: QuestionLayout; title: string; hint: string }> = [
{
value: "AUTO",
@@ -73,10 +64,6 @@ const toDatetimeLocal = (d?: string | Date | null) => {
return localDate.toISOString().slice(0, 16);
};
-/* The three booleans here all gate behaviour, so they all read as switches.
- Two of them used to be bare `accent-color` checkboxes sitting beside a
- custom switch, which made the same kind of decision look like two
- different kinds of control. */
function Toggle({
on,
onChange,
@@ -86,9 +73,6 @@ function Toggle({
on: boolean;
onChange: (v: boolean) => void;
label: string;
- /** Used for a switch another setting is holding on. Left visibly on and
- * inert, rather than hidden: the author needs to see that the requirement
- * applies, and why turning it off here wouldn't work. */
disabled?: boolean;
}) {
return (
@@ -168,9 +152,6 @@ export function FormSettingsDialog({
setRequireSignIn(!!form.requireSignIn);
setCollectRespondentEmail(!!form.collectRespondentEmail);
setOneResponsePerRespondent(!!form.oneResponsePerRespondent);
- // The toggle is derived from whether any domains are stored rather than
- // kept as its own column — an empty allow-list and no restriction are the
- // same thing, and storing both invites them to disagree.
setDomains(form.allowedEmailDomains ?? []);
setRestrictDomains((form.allowedEmailDomains?.length ?? 0) > 0);
setDomainInput("");
@@ -180,9 +161,6 @@ export function FormSettingsDialog({
if (!show || !form) return null;
- /* Whether "everything on one page" is still on the table, decided by the same
- function the renderer uses so the dialog can't offer a layout the renderer
- would override. */
const onePageAllowed = canRenderOnOnePage(segmentCount, ruleCount);
const onePageBlockedReason =
ruleCount > 0 && segmentCount > 1
@@ -191,13 +169,6 @@ export function FormSettingsDialog({
? "This form uses branching, which needs a next page to send people to."
: "This form is split into segments, which are its pages.";
- /* The dependency, stated once.
- *
- * Recording an email, holding someone to one response, and restricting by
- * domain all need an account to read, so each of them turns sign-in on
- * whether or not the author ticked it. Showing that here — rather than only
- * applying it server-side — is the difference between a switch that looks
- * off while behaving as on, and one the author can see is being held. */
const activeDomains = restrictDomains ? domains : [];
const signInImplied =
collectRespondentEmail || oneResponsePerRespondent || activeDomains.length > 0;
@@ -216,9 +187,6 @@ export function FormSettingsDialog({
setDomainInput("");
return;
}
- // A domain needs a dot and a plausible TLD. Without the check a typo like
- // "gmail" would save happily and then match nothing, which reads as the
- // restriction rejecting everyone for no reason.
if (!/^[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}$/.test(candidate)) {
toast.error(`"${domainInput.trim()}" doesn't look like a domain`);
return;
@@ -241,8 +209,7 @@ export function FormSettingsDialog({
setDomainInput("");
};
- const removeDomain = (domain: string) =>
- setDomains((prev) => prev.filter((d) => d !== domain));
+ const removeDomain = (domain: string) => setDomains((prev) => prev.filter((d) => d !== domain));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -264,10 +231,6 @@ export function FormSettingsDialog({
isOpen,
questionLayout,
expiresAt: enableExpiration && expiresAt ? new Date(expiresAt).toISOString() : null,
-
- // The effective value, not the raw switch. The service derives the same
- // thing, but sending what the dialog displayed means a reopened dialog
- // shows the state the author left rather than one shifting under them.
requireSignIn: signInEffective,
collectRespondentEmail,
oneResponsePerRespondent,
@@ -281,8 +244,6 @@ export function FormSettingsDialog({
}
};
- // Footer summary, built from the pending state rather than the saved form,
- // so it previews what Save will actually do.
const summary = [
isOpen ? "Accepting" : "Closed",
enableExpiration && expiresAt ? "expires" : "no expiry",
@@ -322,9 +283,6 @@ export function FormSettingsDialog({
-
- {/* The form is the flex column so the body scrolls and the actions in
- the footer stay pinned. */}
@@ -361,10 +319,6 @@ export function FormSettingsDialog({
{LAYOUT_CHOICES.map((choice) => {
const on = questionLayout === choice.value;
- /* Offered only while it would actually be honoured. A
- branching or segmented form is paginated by the renderer
- whatever this says, and a switch that silently does nothing
- is worse than one that explains why it can't. */
const blocked = choice.value === "ALL_AT_ONCE" && !onePageAllowed;
return (
@@ -415,10 +369,6 @@ export function FormSettingsDialog({
);
})}
-
- {/* Names what AUTO has actually chosen for this form. Without it
- the default is the one option whose behaviour the author
- can't see. */}
{questionLayout === "AUTO" && (
{segmentCount > 1
@@ -426,16 +376,10 @@ export function FormSettingsDialog({
: "This form has one segment, so respondents get one question per page."}
)}
-
- {/* The form was set to one page before it branched or was split.
- The setting is kept rather than rewritten — remove the
- branching and the author's choice comes back — so the honest
- thing is to say it isn't in force right now. */}
{questionLayout === "ALL_AT_ONCE" && !onePageAllowed && (
Saved as one page, but not in use: {onePageBlockedReason} Respondents currently
- get{" "}
- {segmentCount > 1 ? "one segment per page" : "one question per page"}.
+ get {segmentCount > 1 ? "one segment per page" : "one question per page"}.
)}
@@ -472,10 +416,6 @@ export function FormSettingsDialog({
- {/* ── Who can respond ─────────────────────────────────────────
- Ordered so the requirement everything else depends on comes
- first, and each dependent setting sits below the thing it
- switches on. */}
Who can respond
@@ -540,10 +480,6 @@ export function FormSettingsDialog({
type="text"
value={domainInput}
onChange={(e) => setDomainInput(e.target.value)}
- /* Enter adds the domain rather than submitting the
- dialog, which is what a text input inside a form
- does by default — and saving on Enter would strand
- the domain the author had just typed. */
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
@@ -593,15 +529,8 @@ export function FormSettingsDialog({
Add at least one domain, or turn the restriction off.
)}
-
- {/* Subdomain behaviour, spelled out. It's the part an
- author would otherwise have to discover by testing —
- and the part that decides whether they need to list
- every department separately. */}
- Subdomains are included. rishihood.edu.in {" "}
- accepts both kamlesh@rishihood.edu.in and{" "}
- dittya@nst.rishihood.edu.in .
+ Subdomains are included.
)}
diff --git a/apps/web/components/builder/LogicDialog.tsx b/apps/web/components/builder/LogicDialog.tsx
index 26b3c02..1d7196c 100644
--- a/apps/web/components/builder/LogicDialog.tsx
+++ b/apps/web/components/builder/LogicDialog.tsx
@@ -12,6 +12,7 @@ import {
Trash2,
} from "lucide-react";
import { ModalOverlay } from "~/components/ui/ModalOverlay";
+import { CustomSelect } from "~/components/ui/CustomSelect";
import {
MULTI_VALUE_OPERATORS,
VALUELESS_OPERATORS,
@@ -37,11 +38,9 @@ export interface LogicDialogProps {
open: boolean;
onClose: () => void;
- /** The question whose branches are being edited. */
triggerFieldId: string;
triggerFieldLabel: string;
- /** Whole-form context: the flow drives labels, pickers and linting. */
flow: Flow;
labels: FlowLabels;
@@ -59,16 +58,6 @@ export interface LogicDialogProps {
const CONTROL = "cf-input h-9 px-2.5 text-[13px]";
-/**
- * Branching editor for one question, in a dialog rather than the inspector.
- *
- * It lives here because it cannot fit anywhere else. A rule is a sentence with
- * up to six interdependent parts per condition plus two outcomes, and the
- * inspector rail is 288px wide — enough to clip the controls and to put a
- * native dropdown on top of the row it belongs to. Giving it a full-width
- * surface is what makes the feature usable at all; the inspector keeps a
- * read-only summary and a button that opens this.
- */
export function LogicDialog({
open,
onClose,
@@ -86,9 +75,6 @@ export function LogicDialog({
onUpdateCondition,
onDeleteCondition,
}: LogicDialogProps) {
- // Linted across the whole form, not just these rules: the problems worth
- // reporting (unreachable questions, contradicting branches) are only visible
- // from the form as a whole.
const issues = useMemo(() => (open ? lintFlow(flow, labels) : []), [open, flow, labels]);
const issuesByRule = useMemo(() => {
const map = new Map
();
@@ -438,8 +424,6 @@ function IconButton({
);
}
-/** ALL vs ANY. Disabled with one condition, where the distinction is
- * meaningless and offering it only invites the question. */
function MatchToggle({
value,
onChange,
@@ -504,13 +488,6 @@ function ConditionRow({
const isMulti = MULTI_VALUE_OPERATORS.includes(condition.operator);
return (
- /* Stacks on a phone, inline from sm up.
- *
- * This was a single `flex-wrap` row with min-widths on each control, which
- * technically wrapped but produced a ragged two-and-a-half-line block where
- * the operator and its value ended up on different rows from each other.
- * Below sm the three parts each take the full width, which reads as the
- * sentence it is. */
- {/* which question's answer to read — any question, which is the point */}
-
{
- const fieldId = e.target.value;
+ onChange={(fieldId) => {
const next = flow.fieldById.get(fieldId);
const allowed = operatorsForFieldType(next?.type);
- // Changing the source can invalidate the operator (a date question
- // has no "contains"), so fall back to the first sensible one rather
- // than leaving a combination that can never match.
onChange({
fieldId,
...(allowed.includes(condition.operator)
@@ -536,26 +508,20 @@ function ConditionRow({
: { operator: allowed[0] as LogicOperator, value: null }),
});
}}
- className={`${CONTROL} w-full sm:min-w-40 sm:flex-1`}
- aria-label="Question to test"
- >
- {questionOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
({
+ value: option.id,
+ label: option.label,
+ }))}
+ className="w-full sm:min-w-[160px] sm:flex-1"
+ />
+
+ {
- const operator = e.target.value as LogicOperator;
+ onChange={(operator) => {
const wasMulti = MULTI_VALUE_OPERATORS.includes(condition.operator);
const nowMulti = MULTI_VALUE_OPERATORS.includes(operator);
onChange({
- operator,
- // The operand's shape changes with the operator, so a stale value
- // would be rejected by the CHECK constraint on save.
+ operator: operator as LogicOperator,
...(VALUELESS_OPERATORS.includes(operator)
? { value: null }
: wasMulti !== nowMulti
@@ -563,15 +529,12 @@ function ConditionRow({
: {}),
});
}}
- className={`${CONTROL} w-full sm:w-auto sm:min-w-32`}
- aria-label="Comparison"
- >
- {operators.map((op) => (
-
- {OPERATOR_LABELS[op]}
-
- ))}
-
+ options={operators.map((op) => ({
+ value: op,
+ label: OPERATOR_LABELS[op],
+ }))}
+ className="w-full sm:w-auto sm:min-w-[128px]"
+ />
{needsValue && (
@@ -585,8 +548,6 @@ function ConditionRow({
)}
- {/* Right-aligned on its own row when stacked, so it doesn't sit beneath a
- full-width control looking like part of it. */}
@@ -596,14 +557,6 @@ function ConditionRow({
);
}
-/**
- * The operand. Shape follows the operator and the question:
- * · a checkbox list for "is any of" on a question with fixed choices, because
- * typing three option labels by hand is how branching silently stops
- * matching;
- * · a dropdown for a single value on a question with choices;
- * · otherwise a typed input matching the question (number, date, time).
- */
function ValueControl({
condition,
choices,
@@ -674,34 +627,31 @@ function ValueControl({
if (fieldType === "TOGGLE") {
return (
- onChange({ value: e.target.value })}
- className={CONTROL}
- aria-label="Value"
- >
- Choose…
- Yes / on
- No / off
-
+ onChange={(val) => onChange({ value: val })}
+ options={[
+ { value: "", label: "Choose…" },
+ { value: "true", label: "Yes / on" },
+ { value: "false", label: "No / off" },
+ ]}
+ />
);
}
if (choices.length > 0) {
return (
- onChange({ value: e.target.value })}
- className={CONTROL}
- aria-label="Value"
- >
- Choose an answer…
- {choices.map((choice) => (
-
- {choice}
-
- ))}
-
+ onChange={(val) => onChange({ value: val })}
+ options={[
+ { value: "", label: "Choose an answer…" },
+ ...choices.map((choice) => ({
+ value: choice,
+ label: choice,
+ })),
+ ]}
+ />
);
}
@@ -726,7 +676,6 @@ function ValueControl({
);
}
-/** One outcome: what happens, and where it goes. */
function Outcome({
heading,
hint,
@@ -764,60 +713,52 @@ function Outcome({
{hint}
-
{
- const next = (e.target.value || null) as LogicAction | null;
- // Clear the targets that no longer apply, so the row can't hold a
- // destination its action doesn't use.
+ onChange={(val) => {
+ const next = (val || null) as LogicAction | null;
set({
[keys.action]: next,
[keys.field]: next === "JUMP_TO_FIELD" ? (targetFieldId ?? null) : null,
[keys.segment]: next === "JUMP_TO_SEGMENT" ? (targetSegmentId ?? null) : null,
});
}}
- className={CONTROL}
- aria-label={`${heading} action`}
- >
- {allowNone && Nothing — try the next branch }
- {(Object.keys(ACTION_LABELS) as LogicAction[]).map((option) => (
-
- {ACTION_LABELS[option]}
-
- ))}
-
+ options={[
+ ...(allowNone ? [{ value: "", label: "Nothing — try the next branch" }] : []),
+ ...(Object.keys(ACTION_LABELS) as LogicAction[]).map((option) => ({
+ value: option,
+ label: ACTION_LABELS[option],
+ })),
+ ]}
+ />
{action === "JUMP_TO_FIELD" && (
-
set({ [keys.field]: e.target.value || null })}
- className={CONTROL}
- aria-label={`${heading} target question`}
- >
- Choose a question…
- {questionOptions.map((option) => (
-
- {option.label}
-
- ))}
-
+ onChange={(val) => set({ [keys.field]: val || null })}
+ options={[
+ { value: "", label: "Choose a question…" },
+ ...questionOptions.map((option) => ({
+ value: option.id,
+ label: option.label,
+ })),
+ ]}
+ />
)}
{action === "JUMP_TO_SEGMENT" && (
-
set({ [keys.segment]: e.target.value || null })}
- className={CONTROL}
- aria-label={`${heading} target segment`}
- >
- Choose a segment…
- {segmentOptions.length === 0 && No segments yet }
- {segmentOptions.map((option) => (
-
- {option.label}
-
- ))}
-
+ onChange={(val) => set({ [keys.segment]: val || null })}
+ options={[
+ { value: "", label: "Choose a segment…" },
+ ...(segmentOptions.length === 0 ? [{ value: "", label: "No segments yet" }] : []),
+ ...segmentOptions.map((option) => ({
+ value: option.id,
+ label: option.label,
+ })),
+ ]}
+ />
)}
);
diff --git a/apps/web/components/builder/SegmentPanel.tsx b/apps/web/components/builder/SegmentPanel.tsx
index ff77218..e1ddc4c 100644
--- a/apps/web/components/builder/SegmentPanel.tsx
+++ b/apps/web/components/builder/SegmentPanel.tsx
@@ -13,10 +13,7 @@ export interface SegmentLike {
interface SegmentPanelProps {
segments: SegmentLike[];
- /** How many questions sit in each segment, keyed by segment id. */
questionCounts: Record;
- /** Questions not yet filed under any segment. Only non-zero on forms that
- * have never been segmented. */
unassignedCount: number;
selectedSegmentId: string | null;
onSelectSegment: (id: string | null) => void;
@@ -26,17 +23,6 @@ interface SegmentPanelProps {
onDeleteSegment: (id: string) => void;
}
-/**
- * Segment list for the builder — the form's pages, in order.
- *
- * Selecting a segment filters the canvas/outline to its questions, which is
- * what makes segments usable at all: a twenty-question form spread over four
- * pages is unreadable as one flat canvas.
- *
- * Reordering uses up/down buttons rather than drag, matching `FieldOutline`.
- * Segment order decides which page follows which, so it needs to be
- * adjustable without the precision a drag target demands.
- */
export function SegmentPanel({
segments,
questionCounts,
@@ -64,8 +50,6 @@ export function SegmentPanel({
- {/* "All questions" resets the filter. Shown only when there's a
- filter to reset, so an unsegmented form gains no extra chrome. */}
{segments.length > 0 && (
void;
}) {
return (
- /* The card is the click target, not a control inside it.
- *
- * Selection used to hang off two things only: the 28px number badge, and
- * focusing the rename input. Clicking the card anywhere else — its body,
- * the padding around the title, the row with the question count — did
- * nothing, which reads as a broken segment list rather than as a small hit
- * area. `onMouseDown` on the wrapper catches all of it, and runs before
- * focus moves so clicking straight into the rename field selects the
- * segment first.
- *
- * The buttons and the input stop propagation instead of relying on the
- * wrapper ignoring them: reordering segment 2 while viewing segment 1
- * shouldn't quietly change what the canvas is showing. */
{position}
-
- {/* Rename in place. A separate dialog for one text field is more
- clicks than the edit is worth. */}
onRename(e.target.value)}
@@ -201,8 +169,6 @@ function SegmentRow({
className="flex items-center justify-between border-t px-2 py-1.5"
style={{ borderTopColor: "var(--cf-line)" }}
>
- {/* Announces the filter state, which the border alone can't convey to
- a screen reader. */}
- {/* QR + access, mirroring the reference's two-column split */}
QR code
@@ -215,13 +215,88 @@ export function ShareCollaboratorsDialog({
-
-
+
+
-
+ {/* Invite collaborator input inside the access block */}
+ {isOwner && !confirmTransferUserId && (
+
+
+
+
+ Collaborator email
+
+
{
+ setEmail(e.target.value);
+ setShowSuggestions(true);
+ }}
+ onFocus={() => setShowSuggestions(true)}
+ className="cf-input h-9 px-3 text-[13px]"
+ />
+ {showSuggestions && suggestions.length > 0 && (
+
+ {suggestions.map((u) => (
+ {
+ setEmail(u.email);
+ setShowSuggestions(false);
+ }}
+ className="flex w-full cursor-pointer flex-col px-3 py-2 text-left text-[12.5px] transition-colors hover:bg-(--cf-cream-2)"
+ >
+ {u.name}
+
+ {u.email}
+
+
+ ))}
+
+ )}
+
+
+
+ Invite role
+
+
setInviteRole(val as "viewer" | "editor")}
+ options={[
+ { value: "viewer", label: "Viewer" },
+ { value: "editor", label: "Editor" },
+ ]}
+ className="w-28 shrink-0"
+ />
+
+
+
+ Add
+
+
+
+ )}
+
+
{/* Owner */}
@@ -285,17 +360,15 @@ export function ShareCollaboratorsDialog({
Role for {c.email}
-
- handleUpdateRole(c.id, e.target.value as "viewer" | "editor")
- }
- className="cf-input cursor-pointer px-2 py-1 text-[11.5px]"
- >
- Viewer
- Editor
-
+ onChange={(val) => handleUpdateRole(c.id, val as "viewer" | "editor")}
+ options={[
+ { value: "viewer", label: "Viewer" },
+ { value: "editor", label: "Editor" },
+ ]}
+ className="w-28"
+ />
{
setConfirmTransferUserId(c.id);
@@ -327,112 +400,38 @@ export function ShareCollaboratorsDialog({
- {/* Invite, or the transfer confirmation that replaces it */}
- {isOwner &&
- (confirmTransferUserId ? (
-
-
- Transfer ownership
-
-
- Make {confirmTransferUserEmail} the new
- owner? You become an editor and cannot undo this yourself.
-
-
- setConfirmTransferUserId(null)}
- className="cf-btn-outline px-3 py-1.5 text-[12px]"
- >
- Cancel
-
-
- Transfer
-
-
+ {/* Transfer confirmation that replaces invite block if active */}
+ {isOwner && confirmTransferUserId && (
+
+
+ Transfer ownership
+
+
+ Make {confirmTransferUserEmail} the new
+ owner? You become an editor and cannot undo this yourself.
+
+
+ setConfirmTransferUserId(null)}
+ className="cf-btn-outline px-3 py-1.5 text-[12px]"
+ >
+ Cancel
+
+
+ Transfer
+
- ) : (
-
- Invite collaborator
-
-
-
- Collaborator email
-
-
{
- setEmail(e.target.value);
- setShowSuggestions(true);
- }}
- onFocus={() => setShowSuggestions(true)}
- className="cf-input px-3 py-2 text-[13px]"
- />
- {showSuggestions && suggestions.length > 0 && (
-
- {suggestions.map((u) => (
- {
- setEmail(u.email);
- setShowSuggestions(false);
- }}
- className="flex w-full cursor-pointer flex-col px-3 py-2 text-left text-[12.5px] transition-colors hover:bg-(--cf-cream-2)"
- >
- {u.name}
-
- {u.email}
-
-
- ))}
-
- )}
-
-
-
- Invite role
-
-
setInviteRole(e.target.value as "viewer" | "editor")}
- className="cf-input shrink-0 cursor-pointer px-2.5 py-2 text-[12px] sm:w-auto"
- >
- Viewer
- Editor
-
-
-
-
- Add
-
-
-
- ))}
+
+ )}
diff --git a/apps/web/components/builder/mobile/MobileFieldEditorSheet.tsx b/apps/web/components/builder/mobile/MobileFieldEditorSheet.tsx
index 7e3931a..945ab32 100644
--- a/apps/web/components/builder/mobile/MobileFieldEditorSheet.tsx
+++ b/apps/web/components/builder/mobile/MobileFieldEditorSheet.tsx
@@ -10,11 +10,6 @@ interface MobileFieldEditorSheetProps extends FieldInspectorProps {
onClose: () => void;
}
-/**
- * Bottom-sheet editor used in place of the right-hand FieldInspector on
- * mobile. Re-uses the same body (`FieldInspectorBody`) so the per-type
- * editors stay in lockstep with the desktop inspector.
- */
export function MobileFieldEditorSheet({
open,
onClose,
diff --git a/apps/web/components/builder/useBuilderState.ts b/apps/web/components/builder/useBuilderState.ts
new file mode 100644
index 0000000..72d21e9
--- /dev/null
+++ b/apps/web/components/builder/useBuilderState.ts
@@ -0,0 +1,1358 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useParams } from "next/navigation";
+import { Edge, Node, useEdgesState, useNodesState, useReactFlow } from "@xyflow/react";
+import { toast } from "sonner";
+
+import {
+ useGetForm,
+ useListFormFields,
+ useCreateFormField,
+ useUpdateFormField,
+ useDeleteFormField,
+ usePublishForm,
+ useDeleteForm,
+ useListFormSegments,
+ useCreateFormSegment,
+ useUpdateFormSegment,
+ useDeleteFormSegment,
+ useListLogicRules,
+ useCreateLogicRule,
+ useUpdateLogicRule,
+ useDeleteLogicRule,
+} from "~/hooks/api/form";
+import { useDashboard } from "~/providers/dashboard-provider";
+import { indexBetween, isBetween, parseIndex, formatIndex } from "~/lib/fractional-index";
+import { getFieldOptionsArray } from "~/components/builder/FormFieldNode";
+import { BuilderView } from "~/components/builder/BuilderHeader";
+import {
+ buildFlow,
+ type FlowCondition,
+ type FlowRule,
+ type FlowField,
+ type FlowSegment,
+} from "~/lib/form-flow";
+import { describeRule, isRuleComplete, type FlowLabels } from "~/lib/form-logic";
+
+const VIEW_STORAGE_KEY = "canvasflow:builder-view";
+
+export type LocalField = NonNullable
["fields"]>[number] & {
+ _isNew?: boolean;
+};
+export type LocalSegment = NonNullable<
+ ReturnType["segments"]
+>[number] & { _isNew?: boolean };
+export type LocalRule = NonNullable["logicRules"]>[number] & {
+ _isNew?: boolean;
+};
+
+export function useBuilderState() {
+ const params = useParams();
+ const formId = params.formId as string;
+ const reactFlowWrapper = useRef(null);
+
+ const { form, isLoading: formLoading, refetch: refetchForm } = useGetForm(formId);
+ const { fields, isLoading: fieldsLoading, refetch: refetchFields } = useListFormFields(formId);
+ const { segments, refetch: refetchSegments } = useListFormSegments(formId);
+ const { logicRules, refetch: refetchRules } = useListLogicRules(formId);
+
+ const { setIsCreatingForm } = useDashboard();
+ useEffect(() => {
+ if (!formLoading && !fieldsLoading) {
+ setIsCreatingForm(false);
+ }
+ }, [formLoading, fieldsLoading, setIsCreatingForm]);
+
+ const { createFormFieldAsync } = useCreateFormField();
+ const { updateFormFieldAsync } = useUpdateFormField();
+ const { deleteFormFieldAsync } = useDeleteFormField();
+ const { createFormSegmentAsync } = useCreateFormSegment();
+ const { updateFormSegmentAsync } = useUpdateFormSegment();
+ const { deleteFormSegmentAsync } = useDeleteFormSegment();
+ const { createLogicRuleAsync } = useCreateLogicRule();
+ const { updateLogicRuleAsync } = useUpdateLogicRule();
+ const { deleteLogicRuleAsync } = useDeleteLogicRule();
+ const { publishForm, isPending: publishPending } = usePublishForm();
+ const { deleteFormAsync, isPending: deletePending } = useDeleteForm();
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showShareDialog, setShowShareDialog] = useState(false);
+ const [showSettingsDialog, setShowSettingsDialog] = useState(false);
+
+ const [localFields, setLocalFields] = useState([]);
+ const [dirtyIds, setDirtyIds] = useState>(new Set());
+ const [pendingDeletes, setPendingDeletes] = useState>(new Set());
+
+ const [localSegments, setLocalSegments] = useState([]);
+ const [dirtySegmentIds, setDirtySegmentIds] = useState>(new Set());
+ const [pendingSegmentDeletes, setPendingSegmentDeletes] = useState>(new Set());
+
+ const [localRules, setLocalRules] = useState([]);
+ const [dirtyRuleIds, setDirtyRuleIds] = useState>(new Set());
+ const [pendingRuleDeletes, setPendingRuleDeletes] = useState>(new Set());
+
+ const [selectedSegmentId, setSelectedSegmentId] = useState(null);
+
+ const [mobileSegmentsOpen, setMobileSegmentsOpen] = useState(false);
+
+ const [isSaving, setIsSaving] = useState(false);
+ const [justSaved, setJustSaved] = useState(false);
+ const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
+ const pendingNavRef = useRef(null);
+ const isDirtyRef = useRef(false);
+
+ const isDirty =
+ dirtyIds.size > 0 ||
+ pendingDeletes.size > 0 ||
+ dirtySegmentIds.size > 0 ||
+ pendingSegmentDeletes.size > 0 ||
+ dirtyRuleIds.size > 0 ||
+ pendingRuleDeletes.size > 0;
+
+ useEffect(() => {
+ isDirtyRef.current = isDirty;
+ }, [isDirty]);
+
+ useEffect(() => {
+ if (fields && localFields.length === 0) {
+ setLocalFields(fields as LocalField[]);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fields]);
+
+ const segmentsSeededRef = useRef(false);
+ useEffect(() => {
+ if (segments && !segmentsSeededRef.current) {
+ segmentsSeededRef.current = true;
+ setLocalSegments(segments as LocalSegment[]);
+ }
+ }, [segments]);
+
+ const rulesSeededRef = useRef(false);
+ useEffect(() => {
+ if (logicRules && !rulesSeededRef.current) {
+ rulesSeededRef.current = true;
+ setLocalRules(logicRules as LocalRule[]);
+ }
+ }, [logicRules]);
+
+ useEffect(() => {
+ const handler = (e: BeforeUnloadEvent) => {
+ if (isDirtyRef.current) {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ };
+ window.addEventListener("beforeunload", handler);
+ return () => window.removeEventListener("beforeunload", handler);
+ }, []);
+
+ const handleSave = useCallback(async () => {
+ if (!isDirty || isSaving) return;
+ setIsSaving(true);
+ try {
+ const tempIdToRealId = new Map();
+ const updatedVersionById = new Map();
+
+ const tempSegmentIdToRealId = new Map();
+ const updatedSegmentVersionById = new Map();
+
+ const newSegments = localSegments
+ .filter((s) => s._isNew && !pendingSegmentDeletes.has(s.id))
+ .sort((a, b) => parseIndex(a.index) - parseIndex(b.index));
+
+ for (const segment of newSegments) {
+ const created = await createFormSegmentAsync({
+ formId,
+ title: segment.title.trim() || "Untitled segment",
+ description: segment.description ?? undefined,
+ index: parseIndex(segment.index),
+ adoptUnassignedFields: false,
+ });
+ if (created?.id) tempSegmentIdToRealId.set(segment.id, created.id);
+ }
+
+ await Promise.all(
+ localSegments
+ .filter((s) => !s._isNew && dirtySegmentIds.has(s.id) && !pendingSegmentDeletes.has(s.id))
+ .map((s) =>
+ updateFormSegmentAsync({
+ id: s.id,
+ title: s.title.trim() || "Untitled segment",
+ description: s.description ?? undefined,
+ index: String(s.index),
+ expectedVersion: typeof s.version === "number" ? s.version : 0,
+ }).then((data) => {
+ updatedSegmentVersionById.set(s.id, data.version);
+ }),
+ ),
+ );
+
+ const resolveSegmentId = (id: string | null | undefined): string | null => {
+ if (!id) return null;
+ return tempSegmentIdToRealId.get(id) ?? id;
+ };
+
+ const createOps = localFields
+ .filter((f) => f._isNew && !pendingDeletes.has(f.id))
+ .map((f) => {
+ const tempId = f.id;
+ return createFormFieldAsync({
+ formId,
+ segmentId: resolveSegmentId(f.segmentId),
+ label: f.label,
+ type: f.type as any,
+ isRequired: f.isRequired,
+ placeholder: f.placeholder ?? undefined,
+ description: f.description ?? undefined,
+ options: f.options ?? undefined,
+ index: f.index ? parseFloat(String(f.index)) : undefined,
+ }).then((data: any) => {
+ if (data?.id) tempIdToRealId.set(tempId, data.id);
+ });
+ });
+
+ const updateOps = localFields
+ .filter((f) => !f._isNew && dirtyIds.has(f.id) && !pendingDeletes.has(f.id))
+ .map((f) =>
+ updateFormFieldAsync({
+ id: f.id,
+ segmentId: resolveSegmentId(f.segmentId),
+ label: f.label,
+ placeholder: f.placeholder ?? undefined,
+ description: f.description ?? undefined,
+ isRequired: f.isRequired,
+ options: f.options ?? undefined,
+ index: f.index ? String(f.index) : undefined,
+ expectedVersion: typeof (f as any).version === "number" ? (f as any).version : 0,
+ }).then((data) => {
+ updatedVersionById.set(f.id, data.version);
+ }),
+ );
+
+ const deleteOps = localFields
+ .filter((f) => !f._isNew && pendingDeletes.has(f.id))
+ .map((f) => deleteFormFieldAsync({ id: f.id }));
+
+ await Promise.all([...createOps, ...updateOps, ...deleteOps]);
+
+ const resolveFieldId = (id: string | null | undefined): string | null => {
+ if (!id) return null;
+ return tempIdToRealId.get(id) ?? id;
+ };
+
+ const resolveRule = (rule: LocalRule) => ({
+ ...rule,
+ fieldId: resolveFieldId(rule.fieldId) ?? rule.fieldId,
+ targetFieldId: rule.action === "JUMP_TO_FIELD" ? resolveFieldId(rule.targetFieldId) : null,
+ targetSegmentId:
+ rule.action === "JUMP_TO_SEGMENT" ? resolveSegmentId(rule.targetSegmentId) : null,
+ elseTargetFieldId:
+ rule.elseAction === "JUMP_TO_FIELD" ? resolveFieldId(rule.elseTargetFieldId) : null,
+ elseTargetSegmentId:
+ rule.elseAction === "JUMP_TO_SEGMENT" ? resolveSegmentId(rule.elseTargetSegmentId) : null,
+ conditions: (rule.conditions ?? []).map((condition) => ({
+ ...condition,
+ fieldId: resolveFieldId(condition.fieldId) ?? condition.fieldId,
+ })),
+ });
+
+ const isSaveable = (rule: LocalRule): boolean => {
+ const resolved = resolveRule(rule);
+ if (!isRuleComplete(resolved as unknown as FlowRule)) return false;
+ if (pendingDeletes.has(rule.fieldId)) return false;
+
+ const referenced = [
+ resolved.fieldId,
+ resolved.targetFieldId,
+ resolved.elseTargetFieldId,
+ ...resolved.conditions.map((c) => c.fieldId),
+ ].filter((id): id is string => !!id);
+
+ return referenced.every((id) => !pendingDeletes.has(id) && !!resolveFieldId(id));
+ };
+
+ const conditionPayload = (rule: ReturnType) =>
+ rule.conditions.map((condition, i) => ({
+ fieldId: condition.fieldId,
+ operator: condition.operator,
+ value:
+ condition.operator === "IS_EMPTY" || condition.operator === "IS_NOT_EMPTY"
+ ? undefined
+ : condition.value,
+ index: i + 1,
+ }));
+
+ const skippedRules = localRules.filter(
+ (r) =>
+ !pendingRuleDeletes.has(r.id) && (r._isNew || dirtyRuleIds.has(r.id)) && !isSaveable(r),
+ ).length;
+
+ const ruleCreateOps = localRules
+ .filter((r) => r._isNew && !pendingRuleDeletes.has(r.id) && isSaveable(r))
+ .map((r) => {
+ const resolved = resolveRule(r);
+ return createLogicRuleAsync({
+ formId,
+ fieldId: resolved.fieldId,
+ match: resolved.match,
+ conditions: conditionPayload(resolved),
+ action: resolved.action,
+ targetFieldId: resolved.targetFieldId,
+ targetSegmentId: resolved.targetSegmentId,
+ elseAction: resolved.elseAction ?? null,
+ elseTargetFieldId: resolved.elseTargetFieldId,
+ elseTargetSegmentId: resolved.elseTargetSegmentId,
+ index: parseIndex(r.index),
+ });
+ });
+
+ const ruleUpdateOps = localRules
+ .filter(
+ (r) =>
+ !r._isNew && dirtyRuleIds.has(r.id) && !pendingRuleDeletes.has(r.id) && isSaveable(r),
+ )
+ .map((r) => {
+ const resolved = resolveRule(r);
+ return updateLogicRuleAsync({
+ id: r.id,
+ match: resolved.match,
+ conditions: conditionPayload(resolved),
+ action: resolved.action,
+ targetFieldId: resolved.targetFieldId,
+ targetSegmentId: resolved.targetSegmentId,
+ elseAction: resolved.elseAction ?? null,
+ elseTargetFieldId: resolved.elseTargetFieldId,
+ elseTargetSegmentId: resolved.elseTargetSegmentId,
+ index: String(r.index),
+ expectedVersion: typeof r.version === "number" ? r.version : 0,
+ });
+ });
+
+ const ruleDeleteOps = localRules
+ .filter((r) => !r._isNew && pendingRuleDeletes.has(r.id))
+ .map((r) => deleteLogicRuleAsync({ id: r.id }));
+
+ await Promise.all([...ruleCreateOps, ...ruleUpdateOps, ...ruleDeleteOps]);
+
+ await Promise.all(
+ localSegments
+ .filter((s) => !s._isNew && pendingSegmentDeletes.has(s.id))
+ .map((s) => deleteFormSegmentAsync({ id: s.id })),
+ );
+
+ const [freshSegments, freshRules] = await Promise.all([refetchSegments(), refetchRules()]);
+ if (freshSegments.data) setLocalSegments(freshSegments.data as LocalSegment[]);
+ if (freshRules.data) setLocalRules(freshRules.data as LocalRule[]);
+ setDirtySegmentIds(new Set());
+ setPendingSegmentDeletes(new Set());
+ setDirtyRuleIds(new Set());
+ setPendingRuleDeletes(new Set());
+ if (selectedSegmentId && tempSegmentIdToRealId.has(selectedSegmentId)) {
+ setSelectedSegmentId(tempSegmentIdToRealId.get(selectedSegmentId) as string);
+ }
+ if (skippedRules > 0) {
+ toast.warning(
+ `${skippedRules} branching ${skippedRules === 1 ? "rule was" : "rules were"} left unsaved — finish setting ${skippedRules === 1 ? "it" : "them"} up`,
+ );
+ }
+
+ setLocalFields((prev) =>
+ prev
+ .filter((f) => !pendingDeletes.has(f.id))
+ .map((f) => {
+ const realId = tempIdToRealId.get(f.id);
+ const newVersion = updatedVersionById.get(realId ?? f.id);
+ const next: any = { ...f, _isNew: false };
+ if (realId) next.id = realId;
+ if (newVersion !== undefined) next.version = newVersion;
+
+ next.segmentId = resolveSegmentId(f.segmentId);
+ return next;
+ }),
+ );
+ setSelectedNodeId((prev) =>
+ prev && tempIdToRealId.has(prev) ? (tempIdToRealId.get(prev) as string) : prev,
+ );
+ setDirtyIds(new Set());
+ setPendingDeletes(new Set());
+ setJustSaved(true);
+ window.setTimeout(() => setJustSaved(false), 1800);
+ toast.success("Saved");
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const isLockConflict =
+ message.includes("modified by someone else") ||
+ message.includes("raced with another change");
+
+ if (isLockConflict) {
+ toast.error("This form was edited from another session — reloading your view");
+ const [refreshed, freshSegments, freshRules] = await Promise.all([
+ refetchFields(),
+ refetchSegments(),
+ refetchRules(),
+ ]);
+ if (refreshed.data) {
+ setLocalFields(refreshed.data as any);
+ setDirtyIds(new Set());
+ setPendingDeletes(new Set());
+ }
+ if (freshSegments.data) {
+ setLocalSegments(freshSegments.data as LocalSegment[]);
+ setDirtySegmentIds(new Set());
+ setPendingDeletes(new Set());
+ }
+ if (freshRules.data) {
+ setLocalRules(freshRules.data as LocalRule[]);
+ setDirtyRuleIds(new Set());
+ setPendingRuleDeletes(new Set());
+ }
+ } else {
+ toast.error("Some changes failed to save — please retry");
+ }
+ } finally {
+ setIsSaving(false);
+ }
+ }, [
+ isDirty,
+ isSaving,
+ localFields,
+ dirtyIds,
+ pendingDeletes,
+ formId,
+ createFormFieldAsync,
+ updateFormFieldAsync,
+ deleteFormFieldAsync,
+ refetchFields,
+ localSegments,
+ dirtySegmentIds,
+ pendingSegmentDeletes,
+ localRules,
+ dirtyRuleIds,
+ pendingRuleDeletes,
+ selectedSegmentId,
+ createFormSegmentAsync,
+ updateFormSegmentAsync,
+ deleteFormSegmentAsync,
+ createLogicRuleAsync,
+ updateLogicRuleAsync,
+ deleteLogicRuleAsync,
+ refetchSegments,
+ refetchRules,
+ ]);
+
+ const updateLocal = useCallback((id: string, patch: Partial) => {
+ setLocalFields((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
+ setDirtyIds((prev) => new Set(prev).add(id));
+ }, []);
+
+ const localFieldsRef = useRef(localFields);
+ useEffect(() => {
+ localFieldsRef.current = localFields;
+ }, [localFields]);
+
+ const maxLocalIndex = useCallback(
+ () =>
+ localFieldsRef.current.reduce((m, f) => {
+ const v = parseIndex(f.index);
+ return Number.isFinite(v) && v > m ? v : m;
+ }, 0),
+ [],
+ );
+
+ const updateManyLocal = useCallback((patches: Map>) => {
+ if (patches.size === 0) return;
+ setLocalFields((prev) =>
+ prev.map((f) => {
+ const patch = patches.get(f.id);
+ return patch ? { ...f, ...patch } : f;
+ }),
+ );
+ setDirtyIds((prev) => {
+ const next = new Set(prev);
+ for (const id of patches.keys()) next.add(id);
+ return next;
+ });
+ }, []);
+
+ const visibleSegments = useMemo(
+ () =>
+ localSegments
+ .filter((s) => !pendingSegmentDeletes.has(s.id))
+ .sort((a, b) => parseIndex(a.index) - parseIndex(b.index)),
+ [localSegments, pendingSegmentDeletes],
+ );
+
+ const updateSegmentLocal = useCallback((id: string, patch: Partial) => {
+ setLocalSegments((prev) => prev.map((s) => (s.id === id ? { ...s, ...patch } : s)));
+ setDirtySegmentIds((prev) => new Set(prev).add(id));
+ }, []);
+
+ const handleAddSegment = useCallback(() => {
+ const isFirst = visibleSegments.length === 0;
+ const tempId = `new-seg-${Date.now()}`;
+ const nextIndex = isFirst
+ ? 1
+ : Math.max(...visibleSegments.map((s) => parseIndex(s.index) || 0)) + 1;
+
+ const created = {
+ id: tempId,
+ formId,
+ title: isFirst ? "Segment 1" : `Segment ${visibleSegments.length + 1}`,
+ description: null,
+ index: formatIndex(nextIndex),
+ version: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _isNew: true,
+ } as unknown as LocalSegment;
+
+ setLocalSegments((prev) => [...prev, created]);
+ setDirtySegmentIds((prev) => new Set(prev).add(tempId));
+
+ if (isFirst) {
+ const patches = new Map>();
+ for (const field of localFieldsRef.current) {
+ if (pendingDeletes.has(field.id)) continue;
+ patches.set(field.id, { segmentId: tempId });
+ }
+ updateManyLocal(patches);
+ toast.success("Existing questions moved into Segment 1 — add another to branch between them");
+ }
+
+ setSelectedSegmentId(tempId);
+ }, [visibleSegments, formId, pendingDeletes, updateManyLocal]);
+
+ const handleMoveSegment = useCallback(
+ (id: string, direction: "up" | "down") => {
+ const at = visibleSegments.findIndex((s) => s.id === id);
+ if (at === -1) return;
+ const swapWith = direction === "up" ? at - 1 : at + 1;
+ if (swapWith < 0 || swapWith >= visibleSegments.length) return;
+
+ const a = visibleSegments[at]!;
+ const b = visibleSegments[swapWith]!;
+ updateSegmentLocal(a.id, { index: String(b.index) });
+ updateSegmentLocal(b.id, { index: String(a.index) });
+ },
+ [visibleSegments, updateSegmentLocal],
+ );
+
+ const handleDeleteSegment = useCallback(
+ (id: string) => {
+ const at = visibleSegments.findIndex((s) => s.id === id);
+ const adoptive = at > 0 ? visibleSegments[at - 1] : visibleSegments[at + 1];
+ const adoptiveId = adoptive?.id ?? null;
+
+ const patches = new Map>();
+ for (const field of localFieldsRef.current) {
+ if (field.segmentId === id) {
+ patches.set(field.id, { segmentId: adoptiveId });
+ }
+ }
+ if (patches.size > 0) updateManyLocal(patches);
+
+ setLocalRules((prev) => prev.filter((r) => r.targetSegmentId !== id || r._isNew === true));
+ setPendingRuleDeletes((prev) => {
+ const next = new Set(prev);
+ for (const rule of localRules) {
+ if (rule.targetSegmentId === id && !rule._isNew) next.add(rule.id);
+ }
+ return next;
+ });
+
+ setPendingSegmentDeletes((prev) => new Set(prev).add(id));
+ setDirtySegmentIds((prev) => new Set(prev).add(id));
+ setSelectedSegmentId((prev) => (prev === id ? null : prev));
+ toast("Segment removed — save to confirm", { duration: 2000 });
+ },
+ [visibleSegments, localRules, updateManyLocal],
+ );
+
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
+ const [selectedNodeId, setSelectedNodeId] = useState(null);
+ const [isLocked, setIsLocked] = useState(false);
+
+ const [branchingFieldId, setBranchingFieldId] = useState(null);
+
+ const rulesForField = useCallback(
+ (fieldId: string | null) => {
+ if (!fieldId) return [] as FlowRule[];
+ return localRules
+ .filter((r) => r.fieldId === fieldId && !pendingRuleDeletes.has(r.id))
+ .sort((a, b) => parseIndex(a.index) - parseIndex(b.index)) as unknown as FlowRule[];
+ },
+ [localRules, pendingRuleDeletes],
+ );
+
+ const markRuleDirty = useCallback((id: string) => {
+ setDirtyRuleIds((prev) => new Set(prev).add(id));
+ }, []);
+
+ const handleAddRule = useCallback(() => {
+ const fieldId = branchingFieldId;
+ if (!fieldId) return;
+
+ const tempId = `new-rule-${Date.now()}`;
+ const siblings = localRules.filter((r) => r.fieldId === fieldId);
+ const nextIndex =
+ siblings.length === 0 ? 1 : Math.max(...siblings.map((r) => parseIndex(r.index) || 0)) + 1;
+
+ const created = {
+ id: tempId,
+ formId,
+ fieldId,
+ match: "ALL",
+ conditions: [
+ {
+ id: `new-cond-${Date.now()}`,
+ ruleId: tempId,
+ fieldId,
+ operator: "EQUALS",
+ value: "",
+ index: "1",
+ },
+ ],
+ action: "JUMP_TO_FIELD",
+ targetFieldId: null,
+ targetSegmentId: null,
+ elseAction: null,
+ elseTargetFieldId: null,
+ elseTargetSegmentId: null,
+ index: formatIndex(nextIndex),
+ version: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _isNew: true,
+ } as unknown as LocalRule;
+
+ setLocalRules((prev) => [...prev, created]);
+ markRuleDirty(tempId);
+ }, [branchingFieldId, localRules, formId, markRuleDirty]);
+
+ const handleUpdateRule = useCallback(
+ (id: string, patch: Partial) => {
+ setLocalRules(
+ (prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)) as unknown as LocalRule[],
+ );
+ markRuleDirty(id);
+ },
+ [markRuleDirty],
+ );
+
+ const handleDeleteRule = useCallback(
+ (id: string) => {
+ const rule = localRules.find((r) => r.id === id);
+ if (rule?._isNew) {
+ setLocalRules((prev) => prev.filter((r) => r.id !== id));
+ setDirtyRuleIds((prev) => {
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ return;
+ }
+ setPendingRuleDeletes((prev) => new Set(prev).add(id));
+ markRuleDirty(id);
+ },
+ [localRules, markRuleDirty],
+ );
+
+ const handleDuplicateRule = useCallback(
+ (id: string) => {
+ const source = localRules.find((r) => r.id === id);
+ if (!source) return;
+
+ const tempId = `new-rule-${Date.now()}`;
+ const siblings = localRules.filter((r) => r.fieldId === source.fieldId);
+ const nextIndex = Math.max(...siblings.map((r) => parseIndex(r.index) || 0)) + 1;
+
+ const copy = {
+ ...source,
+ id: tempId,
+ index: formatIndex(nextIndex),
+ version: 0,
+ _isNew: true,
+ conditions: (source.conditions ?? []).map((condition: FlowCondition, i: number) => ({
+ ...condition,
+ id: `new-cond-${Date.now()}-${i}`,
+ ruleId: tempId,
+ })),
+ } as unknown as LocalRule;
+
+ setLocalRules((prev) => [...prev, copy]);
+ markRuleDirty(tempId);
+ },
+ [localRules, markRuleDirty],
+ );
+
+ const handleMoveRule = useCallback(
+ (id: string, direction: "up" | "down") => {
+ const source = localRules.find((r) => r.id === id);
+ if (!source) return;
+
+ const siblings = rulesForField(source.fieldId);
+ const at = siblings.findIndex((r) => r.id === id);
+ const swapWith = direction === "up" ? at - 1 : at + 1;
+ if (at === -1 || swapWith < 0 || swapWith >= siblings.length) return;
+
+ const a = siblings[at]!;
+ const b = siblings[swapWith]!;
+ handleUpdateRule(a.id, { index: String(b.index) });
+ handleUpdateRule(b.id, { index: String(a.index) });
+ },
+ [localRules, rulesForField, handleUpdateRule],
+ );
+
+ const handleAddCondition = useCallback(
+ (ruleId: string) => {
+ setLocalRules((prev) =>
+ prev.map((r) => {
+ if (r.id !== ruleId) return r;
+ const conditions = (r.conditions ?? []) as FlowCondition[];
+ const nextIndex =
+ conditions.length === 0
+ ? 1
+ : Math.max(...conditions.map((c) => parseIndex(c.index) || 0)) + 1;
+ return {
+ ...r,
+ conditions: [
+ ...conditions,
+ {
+ id: `new-cond-${Date.now()}`,
+ ruleId,
+ fieldId: r.fieldId,
+ operator: "EQUALS",
+ value: "",
+ index: String(nextIndex),
+ },
+ ],
+ } as unknown as LocalRule;
+ }),
+ );
+ markRuleDirty(ruleId);
+ },
+ [markRuleDirty],
+ );
+
+ const handleUpdateCondition = useCallback(
+ (ruleId: string, conditionId: string, patch: Partial) => {
+ setLocalRules((prev) =>
+ prev.map((r) =>
+ r.id === ruleId
+ ? ({
+ ...r,
+ conditions: ((r.conditions ?? []) as FlowCondition[]).map((c) =>
+ c.id === conditionId ? { ...c, ...patch } : c,
+ ),
+ } as unknown as LocalRule)
+ : r,
+ ),
+ );
+ markRuleDirty(ruleId);
+ },
+ [markRuleDirty],
+ );
+
+ const handleDeleteCondition = useCallback(
+ (ruleId: string, conditionId: string) => {
+ setLocalRules((prev) =>
+ prev.map((r) =>
+ r.id === ruleId
+ ? ({
+ ...r,
+ conditions: ((r.conditions ?? []) as FlowCondition[]).filter(
+ (c) => c.id !== conditionId,
+ ),
+ } as unknown as LocalRule)
+ : r,
+ ),
+ );
+ markRuleDirty(ruleId);
+ },
+ [markRuleDirty],
+ );
+
+ const [view, setView] = useState("canvas");
+
+ useEffect(() => {
+ try {
+ const saved = window.localStorage.getItem(VIEW_STORAGE_KEY);
+ if (saved === "canvas" || saved === "outline") setView(saved);
+ else if (saved === "list") setView("outline");
+ } catch {
+ /* Safe fallback */
+ }
+ }, []);
+
+ const handleViewChange = useCallback((next: BuilderView) => {
+ setView(next);
+ try {
+ window.localStorage.setItem(VIEW_STORAGE_KEY, next);
+ } catch {
+ /* Safe fallback */
+ }
+ }, []);
+
+ const [label, setLabel] = useState("");
+ const [placeholder, setPlaceholder] = useState("");
+ const [isRequired, setIsRequired] = useState(false);
+ const [description, setDescription] = useState("");
+ const [optionsList, setOptionsList] = useState([]);
+
+ const { screenToFlowPosition, zoomIn, zoomOut, fitView } = useReactFlow();
+
+ useEffect(() => {
+ const visible = localFields
+ .filter((f) => !pendingDeletes.has(f.id))
+ .filter((f) => selectedSegmentId === null || f.segmentId === selectedSegmentId)
+ .sort((a, b) => {
+ const d = parseIndex(a.index) - parseIndex(b.index);
+ return d !== 0 ? d : a.id.localeCompare(b.id);
+ });
+ setNodes((prevNodes) => {
+ const prevById = new Map(prevNodes.map((n) => [n.id, n]));
+ return visible.map((field, idx) => {
+ const existing = prevById.get(field.id);
+ if (existing) {
+ return { ...existing, data: { field } };
+ }
+ const p = (typeof field.options === "object" && field.options ? (field.options as any) : {})
+ .position || { x: 300, y: idx * 200 + 80 };
+ return {
+ id: field.id,
+ type: "formField",
+ position: p,
+ data: { field },
+ };
+ });
+ });
+ const mappedEdges: Edge[] = [];
+ const onCanvas = new Set(visible.map((f) => f.id));
+
+ for (let i = 0; i < visible.length - 1; i++) {
+ const s = visible[i];
+ const t = visible[i + 1];
+ if (s && t)
+ mappedEdges.push({
+ id: `e-${s.id}-${t.id}`,
+ source: s.id,
+ target: t.id,
+ animated: true,
+ style: {
+ stroke: "#f66f00",
+ strokeWidth: 1.5,
+ strokeOpacity: 0.55,
+ strokeDasharray: "4,4",
+ },
+ });
+ }
+
+ const targetOf = (
+ action: string | null | undefined,
+ fieldTarget: string | null | undefined,
+ segmentTarget: string | null | undefined,
+ ): string | null => {
+ if (action === "JUMP_TO_FIELD") return fieldTarget ?? null;
+ if (action === "JUMP_TO_SEGMENT" && segmentTarget) {
+ return visible.find((f) => f.segmentId === segmentTarget)?.id ?? null;
+ }
+ return null;
+ };
+
+ for (const rule of localRules) {
+ if (pendingRuleDeletes.has(rule.id)) continue;
+ if (!onCanvas.has(rule.fieldId)) continue;
+
+ const conditionCount = rule.conditions?.length ?? 0;
+ const edgeLabel =
+ conditionCount === 0
+ ? "incomplete"
+ : conditionCount === 1
+ ? "if"
+ : `if ${rule.match === "ANY" ? "any" : "all"} ${conditionCount}`;
+
+ const sides: Array<{ suffix: string; target: string | null; label: string; solid: boolean }> =
+ [
+ {
+ suffix: "then",
+ target: targetOf(rule.action, rule.targetFieldId, rule.targetSegmentId),
+ label: edgeLabel,
+ solid: true,
+ },
+ {
+ suffix: "else",
+ target: targetOf(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId),
+ label: "otherwise",
+ solid: false,
+ },
+ ];
+
+ for (const side of sides) {
+ if (!side.target || !onCanvas.has(side.target) || side.target === rule.fieldId) continue;
+ mappedEdges.push({
+ id: `branch-${rule.id}-${side.suffix}`,
+ source: rule.fieldId,
+ target: side.target,
+ label: side.label,
+ labelStyle: { fontSize: 10, fontFamily: "monospace" },
+ labelBgStyle: { fill: "var(--cf-cream)" },
+ style: side.solid
+ ? { stroke: "#1a1d29", strokeWidth: 2 }
+ : { stroke: "#1a1d29", strokeWidth: 1.5, strokeDasharray: "6,3", strokeOpacity: 0.7 },
+ });
+ }
+ }
+
+ setEdges(mappedEdges);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [localFields, pendingDeletes, selectedSegmentId, localRules, pendingRuleDeletes]);
+
+ const segmentQuestionCounts = useMemo(() => {
+ const counts: Record = {};
+ for (const field of localFields) {
+ if (pendingDeletes.has(field.id) || !field.segmentId) continue;
+ counts[field.segmentId] = (counts[field.segmentId] ?? 0) + 1;
+ }
+ return counts;
+ }, [localFields, pendingDeletes]);
+
+ const unassignedQuestionCount = useMemo(
+ () => localFields.filter((f) => !pendingDeletes.has(f.id) && !f.segmentId).length,
+ [localFields, pendingDeletes],
+ );
+
+ const branchTargetSegments = useMemo(
+ () =>
+ visibleSegments.map((s, i) => ({
+ id: s.id,
+ label: `${i + 1}. ${s.title?.trim() || `Segment ${i + 1}`}`,
+ })),
+ [visibleSegments],
+ );
+
+ const activeRuleCount = useMemo(
+ () => localRules.filter((r) => !pendingRuleDeletes.has(r.id)).length,
+ [localRules, pendingRuleDeletes],
+ );
+
+ const draftFlow = useMemo(
+ () =>
+ buildFlow(
+ localFields.filter((f) => !pendingDeletes.has(f.id)) as unknown as FlowField[],
+ visibleSegments as unknown as FlowSegment[],
+ localRules.filter((r) => !pendingRuleDeletes.has(r.id)) as unknown as FlowRule[],
+ ),
+ [localFields, pendingDeletes, visibleSegments, localRules, pendingRuleDeletes],
+ );
+
+ const flowLabels: FlowLabels = useMemo(() => {
+ const positionOf = new Map(draftFlow.order.map((f, i) => [f.id, i + 1]));
+ return {
+ fieldLabel: (fieldId) => {
+ const field = draftFlow.fieldById.get(fieldId);
+ if (!field) return "a deleted question";
+ const name =
+ field.label?.trim() || `Untitled ${field.type.replace("_", " ").toLowerCase()}`;
+ return `Q${positionOf.get(fieldId) ?? "?"} ${name}`;
+ },
+ segmentLabel: (segmentId) => {
+ const at = draftFlow.segments.findIndex((s) => s.id === segmentId);
+ if (at === -1) return "a deleted segment";
+ return draftFlow.segments[at]?.title?.trim() || `Segment ${at + 1}`;
+ },
+ };
+ }, [draftFlow]);
+
+ const selectedFieldRuleSummaries = useMemo(
+ () => rulesForField(selectedNodeId).map((rule) => describeRule(rule, flowLabels)),
+ [rulesForField, selectedNodeId, flowLabels],
+ );
+
+ const selectedFieldIncompleteRules = useMemo(
+ () => rulesForField(selectedNodeId).filter((rule) => !isRuleComplete(rule)).length,
+ [rulesForField, selectedNodeId],
+ );
+
+ const isSelectedFieldLastInSegment = useMemo(() => {
+ if (!selectedNodeId) return false;
+ const selected = localFields.find((f) => f.id === selectedNodeId);
+ if (!selected?.segmentId) return false;
+ const siblings = localFields
+ .filter((f) => !pendingDeletes.has(f.id) && f.segmentId === selected.segmentId)
+ .sort((a, b) => parseIndex(a.index) - parseIndex(b.index));
+ return siblings[siblings.length - 1]?.id === selectedNodeId;
+ }, [localFields, pendingDeletes, selectedNodeId]);
+
+ const selectedField = useMemo(
+ () => localFields.find((f) => f.id === selectedNodeId) ?? null,
+ [localFields, selectedNodeId],
+ );
+
+ useEffect(() => {
+ if (selectedField) {
+ setLabel(selectedField.label);
+ setPlaceholder(selectedField.placeholder || "");
+ setIsRequired(selectedField.isRequired);
+ setDescription(selectedField.description || "");
+ setOptionsList(getFieldOptionsArray(selectedField));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedField?.id]);
+
+ const nextSegmentId = useCallback(
+ () => selectedSegmentId ?? visibleSegments[visibleSegments.length - 1]?.id ?? null,
+ [selectedSegmentId, visibleSegments],
+ );
+
+ const onDragStart = (event: React.DragEvent, type: string) => {
+ event.dataTransfer.setData("application/reactflow", type);
+ event.dataTransfer.effectAllowed = "move";
+ };
+
+ const onDragOver = useCallback((event: React.DragEvent) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = "move";
+ }, []);
+
+ const onDrop = useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ const type = event.dataTransfer.getData("application/reactflow");
+ if (!type || !reactFlowWrapper.current) return;
+ const position = screenToFlowPosition({
+ x: event.clientX,
+ y: event.clientY,
+ });
+ const tempId = `new-${Date.now()}`;
+ const nextIndex = formatIndex(maxLocalIndex() + 1);
+ const newField = {
+ id: tempId,
+ formId,
+ segmentId: nextSegmentId(),
+ label: "",
+ labelKey: "field",
+ placeholder: null,
+ isRequired: false,
+ index: nextIndex,
+ type: type as any,
+ options: { position },
+ description: null,
+ version: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _isNew: true,
+ };
+ setLocalFields((prev) => [...prev, newField]);
+ setDirtyIds((prev) => new Set(prev).add(tempId));
+ setSelectedNodeId(tempId);
+ },
+ [screenToFlowPosition, formId, maxLocalIndex, nextSegmentId],
+ );
+
+ const onNodeClick = useCallback((_: React.MouseEvent, node: any) => {
+ setSelectedNodeId(node.id);
+ }, []);
+
+ const onPaneClick = useCallback(() => setSelectedNodeId(null), []);
+
+ const onNodeDragStop = useCallback(
+ (_event: any, node: Node) => {
+ const currentField = localFields.find((f) => f.id === node.id);
+ if (!currentField) return;
+
+ const currentOpts =
+ typeof currentField.options === "object" && currentField.options
+ ? (currentField.options as Record)
+ : {};
+ const positionPatch = { options: { ...currentOpts, position: node.position } };
+
+ const visible = localFields.filter((f) => !pendingDeletes.has(f.id));
+
+ const livePos = new Map(nodes.map((n) => [n.id, n.position]));
+ livePos.set(node.id, node.position);
+ const posOf = (f: LocalField) =>
+ livePos.get(f.id) ??
+ ((f.options as any)?.position as { x: number; y: number } | undefined) ?? { x: 0, y: 0 };
+
+ const ordered = [...visible].sort((a, b) => {
+ const pa = posOf(a);
+ const pb = posOf(b);
+ if (pa.y !== pb.y) return pa.y - pb.y;
+ if (pa.x !== pb.x) return pa.x - pb.x;
+ return parseIndex(a.index) - parseIndex(b.index);
+ });
+
+ const at = ordered.findIndex((f) => f.id === node.id);
+ if (at === -1) {
+ updateLocal(node.id, positionPatch);
+ return;
+ }
+
+ const before = at > 0 ? parseIndex(ordered[at - 1]!.index) : null;
+ const after = at < ordered.length - 1 ? parseIndex(ordered[at + 1]!.index) : null;
+ const current = parseIndex(currentField.index);
+
+ if (isBetween(current, before, after)) {
+ updateLocal(node.id, positionPatch);
+ return;
+ }
+
+ const next = indexBetween(before, after);
+ if (next !== null) {
+ updateLocal(node.id, { ...positionPatch, index: next });
+ return;
+ }
+
+ const base = maxLocalIndex() + 1;
+ const patches = new Map>();
+ ordered.forEach((f, i) => {
+ patches.set(f.id, { index: formatIndex(base + i) });
+ });
+ patches.set(node.id, { ...patches.get(node.id), ...positionPatch });
+ updateManyLocal(patches);
+ },
+ [localFields, pendingDeletes, nodes, updateLocal, updateManyLocal, maxLocalIndex],
+ );
+
+ const handleRequiredChange = useCallback(
+ (checked: boolean) => {
+ if (!selectedField) return;
+ setIsRequired(checked);
+ updateLocal(selectedField.id, { isRequired: checked });
+ },
+ [selectedField, updateLocal],
+ );
+
+ const handleDeleteField = useCallback(() => {
+ if (!selectedNodeId) return;
+ setPendingDeletes((prev) => new Set(prev).add(selectedNodeId));
+ setDirtyIds((prev) => new Set(prev).add(selectedNodeId));
+ setSelectedNodeId(null);
+ toast("Field removed — save to confirm", { duration: 2000 });
+ }, [selectedNodeId]);
+
+ const [mobileAddOpen, setMobileAddOpen] = useState(false);
+ const [mobileEditorOpen, setMobileEditorOpen] = useState(false);
+
+ const visibleSortedFields = useMemo(
+ () =>
+ localFields
+ .filter((f) => !pendingDeletes.has(f.id))
+ .filter((f) => selectedSegmentId === null || f.segmentId === selectedSegmentId)
+ .sort((a, b) => {
+ const d = parseIndex(a.index) - parseIndex(b.index);
+ return d !== 0 ? d : a.id.localeCompare(b.id);
+ }),
+ [localFields, pendingDeletes, selectedSegmentId],
+ );
+
+ const handleMobileTapField = useCallback((id: string) => {
+ setSelectedNodeId(id);
+ setMobileEditorOpen(true);
+ }, []);
+
+ const handleCloseMobileEditor = useCallback(() => {
+ setMobileEditorOpen(false);
+ setSelectedNodeId(null);
+ }, []);
+
+ const handleMobileMove = useCallback(
+ (id: string, direction: "up" | "down") => {
+ const list = visibleSortedFields;
+ const i = list.findIndex((f) => f.id === id);
+ if (i === -1) return;
+ const j = direction === "up" ? i - 1 : i + 1;
+ if (j < 0 || j >= list.length) return;
+
+ const beforeField = direction === "up" ? list[j - 1] : list[j];
+ const afterField = direction === "up" ? list[j] : list[j + 1];
+
+ const next = indexBetween(
+ beforeField ? parseIndex(beforeField.index) : null,
+ afterField ? parseIndex(afterField.index) : null,
+ );
+
+ if (next !== null) {
+ updateLocal(id, { index: next });
+ return;
+ }
+
+ const reordered = [...list];
+ const [moved] = reordered.splice(i, 1);
+ reordered.splice(j, 0, moved!);
+
+ const base = maxLocalIndex() + 1;
+ const patches = new Map>();
+ reordered.forEach((f, k) => {
+ patches.set(f.id, { index: formatIndex(base + k) });
+ });
+ updateManyLocal(patches);
+ },
+ [visibleSortedFields, updateLocal, updateManyLocal, maxLocalIndex],
+ );
+
+ const appendField = useCallback(
+ (type: string) => {
+ const last = visibleSortedFields[visibleSortedFields.length - 1];
+ const nextIndex = formatIndex(maxLocalIndex() + 1);
+
+ const lastPos = (last?.options as any)?.position as { x: number; y: number } | undefined;
+ const position = lastPos
+ ? { x: lastPos.x, y: lastPos.y + 200 }
+ : { x: 300, y: visibleSortedFields.length * 200 + 80 };
+
+ const tempId = `new-${Date.now()}`;
+ const newField: LocalField = {
+ id: tempId,
+ formId,
+ segmentId: nextSegmentId(),
+ label: "",
+ labelKey: "field",
+ placeholder: null,
+ index: nextIndex,
+ isRequired: false,
+ type: type as any,
+ options: { position } as any,
+ description: null,
+ version: 0,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ _isNew: true,
+ };
+ setLocalFields((prev) => [...prev, newField]);
+ setDirtyIds((prev) => new Set(prev).add(tempId));
+ setSelectedNodeId(tempId);
+ },
+ [visibleSortedFields, formId, maxLocalIndex, nextSegmentId],
+ );
+
+ const handleMobileAddField = useCallback(
+ (type: string) => {
+ appendField(type);
+ setMobileAddOpen(false);
+ setMobileEditorOpen(true);
+ },
+ [appendField],
+ );
+
+ return {
+ formId,
+ reactFlowWrapper,
+ form,
+ formLoading,
+ refetchForm,
+ fieldsLoading,
+ refetchFields,
+ publishPending,
+ deletePending,
+ showDeleteConfirm,
+ setShowDeleteConfirm,
+ showShareDialog,
+ setShowShareDialog,
+ showSettingsDialog,
+ setShowSettingsDialog,
+ localFields,
+ setLocalFields,
+ setDirtyIds,
+ setPendingDeletes,
+ localSegments,
+ setLocalSegments,
+ setDirtySegmentIds,
+ setPendingSegmentDeletes,
+ localRules,
+ setLocalRules,
+ setDirtyRuleIds,
+ setPendingRuleDeletes,
+ selectedSegmentId,
+ setSelectedSegmentId,
+ mobileSegmentsOpen,
+ setMobileSegmentsOpen,
+ isSaving,
+ setIsSaving,
+ justSaved,
+ setJustSaved,
+ showUnsavedDialog,
+ setShowUnsavedDialog,
+ pendingNavRef,
+ isDirtyRef,
+ isDirty,
+ handleSave,
+ updateLocal,
+ maxLocalIndex,
+ updateManyLocal,
+ visibleSegments,
+ updateSegmentLocal,
+ handleAddSegment,
+ handleMoveSegment,
+ handleDeleteSegment,
+ nodes,
+ setNodes,
+ onNodesChange,
+ edges,
+ setEdges,
+ onEdgesChange,
+ selectedNodeId,
+ setSelectedNodeId,
+ isLocked,
+ setIsLocked,
+ branchingFieldId,
+ setBranchingFieldId,
+ rulesForField,
+ markRuleDirty,
+ handleAddRule,
+ handleUpdateRule,
+ handleDeleteRule,
+ handleDuplicateRule,
+ handleMoveRule,
+ handleAddCondition,
+ handleUpdateCondition,
+ handleDeleteCondition,
+ view,
+ setView,
+ handleViewChange,
+ label,
+ setLabel,
+ placeholder,
+ setPlaceholder,
+ isRequired,
+ setIsRequired,
+ description,
+ setDescription,
+ optionsList,
+ setOptionsList,
+ screenToFlowPosition,
+ zoomIn,
+ zoomOut,
+ fitView,
+ onDragStart,
+ onDragOver,
+ onDrop,
+ onNodeClick,
+ onPaneClick,
+ onNodeDragStop,
+ handleRequiredChange,
+ handleDeleteField,
+ mobileAddOpen,
+ setMobileAddOpen,
+ mobileEditorOpen,
+ setMobileEditorOpen,
+ visibleSortedFields,
+ handleMobileTapField,
+ handleCloseMobileEditor,
+ handleMobileMove,
+ appendField,
+ handleMobileAddField,
+ segmentQuestionCounts,
+ unassignedQuestionCount,
+ branchTargetSegments,
+ activeRuleCount,
+ draftFlow,
+ flowLabels,
+ selectedFieldRuleSummaries,
+ selectedFieldIncompleteRules,
+ isSelectedFieldLastInSegment,
+ selectedField,
+ publishForm,
+ deleteFormAsync,
+ };
+}
diff --git a/apps/web/components/docs/DocsSectionNav.tsx b/apps/web/components/docs/DocsSectionNav.tsx
index 5c2c64a..4cf416f 100644
--- a/apps/web/components/docs/DocsSectionNav.tsx
+++ b/apps/web/components/docs/DocsSectionNav.tsx
@@ -2,35 +2,14 @@
import React, { useEffect, useRef, useState } from "react";
-/**
- * The docs "On this page" index, with the current section highlighted.
- *
- * Position is derived from a scroll listener reading each section's
- * `getBoundingClientRect().top` rather than from IntersectionObserver. The
- * question being answered is "which section am I inside", and an observer
- * answers a different one — "which sections are visible" — which needs extra
- * bookkeeping once two sections share the viewport, or once a section is
- * taller than it. Comparing tops against a single line is both simpler and
- * exact. Reads are throttled to one per frame, so the listener costs a
- * rect-per-section per frame while scrolling and nothing at rest.
- */
-
type Section = { id: string; title: string };
-/**
- * Where the "you are here" line sits, in px from the top of the viewport.
- * Below the 64px sticky navbar and the 96px `scroll-margin-top` that anchor
- * jumps land on, so the section you just jumped to is the one that lights up
- * rather than the one above it.
- */
const ACTIVE_LINE = 140;
export function DocsSectionNav({ sections }: { sections: Section[] }) {
const [activeId, setActiveId] = useState(sections[0]?.id ?? "");
const listRef = useRef(null);
- // Prop identity comes from the RSC payload; key the effect off the ids so it
- // can't re-subscribe on unrelated re-renders.
const ids = sections.map((s) => s.id).join(",");
useEffect(() => {
@@ -45,20 +24,12 @@ export function DocsSectionNav({ sections }: { sections: Section[] }) {
const measure = () => {
frame = 0;
-
- // The last section whose top has passed the line is the one being read.
- // Sections are in document order, so the first one still below the line
- // ends the search.
let current = els[0]!;
for (const el of els) {
if (el.getBoundingClientRect().top <= ACTIVE_LINE) current = el;
else break;
}
- // Bottom of the document: the final section wins outright. A short last
- // section — or one sitting above the footer — may never get its top past
- // the line, which would otherwise leave the previous entry highlighted
- // while the reader is plainly looking at the last one.
const atBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
@@ -66,7 +37,6 @@ export function DocsSectionNav({ sections }: { sections: Section[] }) {
};
const onScroll = () => {
- // Coalesce to one measurement per frame; scroll fires far more often.
if (frame === 0) frame = window.requestAnimationFrame(measure);
};
@@ -81,10 +51,6 @@ export function DocsSectionNav({ sections }: { sections: Section[] }) {
};
}, [ids]);
- // Keep the highlighted row visible inside the index's own scroll box.
- // Container `scrollTop` is set directly instead of calling
- // `scrollIntoView`, which is free to scroll the window as well and would
- // fight the reader for control of the page.
useEffect(() => {
const list = listRef.current;
if (!list || !activeId) return;
@@ -118,14 +84,9 @@ export function DocsSectionNav({ sections }: { sections: Section[] }) {
href={`#${s.id}`}
data-section={s.id}
aria-current={active ? "true" : undefined}
- // Clicking sets the highlight immediately. Smooth scrolling is
- // in effect page-wide, so waiting for the scroll to land would
- // leave the row the reader just clicked looking inert.
onClick={() => setActiveId(s.id)}
className="flex items-baseline gap-3 py-2.5 pl-3 text-[13.5px] transition-colors hover:text-foreground"
style={{
- // The marker width is always reserved and only inked when
- // active, so highlighting a row can't shift the list by 2px.
borderLeftWidth: 2,
borderLeftStyle: "solid",
borderLeftColor: active ? "var(--hex-ink)" : "transparent",
diff --git a/apps/web/components/forms/FileUploadField.tsx b/apps/web/components/forms/FileUploadField.tsx
new file mode 100644
index 0000000..9f2c0cf
--- /dev/null
+++ b/apps/web/components/forms/FileUploadField.tsx
@@ -0,0 +1,373 @@
+"use client";
+
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import { AlertCircle, CheckCircle2, Loader2, UploadCloud, X } from "lucide-react";
+
+import {
+ fetchUploadLimits,
+ formatBytes,
+ limitLabel,
+ startUpload,
+ waitForUpload,
+ UploadError,
+ type UploadedFileRef,
+ type UploadLimits,
+} from "~/lib/upload";
+
+interface FileUploadFieldProps {
+ formId: string;
+ fieldId: string;
+ inputId: string;
+ placeholder?: string | null;
+ options?: { accept?: string[]; maxMb?: number; maxFiles?: number } | null;
+ value: UploadedFileRef | UploadedFileRef[] | null | undefined;
+ onChange: (value: UploadedFileRef[] | UploadedFileRef | null) => void;
+ disabled?: boolean;
+}
+
+export function FileUploadField({
+ formId,
+ fieldId,
+ inputId,
+ placeholder,
+ options,
+ value,
+ onChange,
+ disabled = false,
+}: FileUploadFieldProps) {
+ const maxFiles = Math.max(1, options?.maxFiles ?? 1);
+ const multiple = maxFiles > 1;
+
+ const files = React.useMemo(
+ () => (Array.isArray(value) ? value : value ? [value] : []),
+ [value],
+ );
+
+ const [inFlight, setInFlight] = useState>(
+ [],
+ );
+ const [message, setMessage] = useState(null);
+ const [dragging, setDragging] = useState(false);
+
+ const [limits, setLimits] = useState(null);
+ useEffect(() => {
+ let active = true;
+ void fetchUploadLimits().then((next) => {
+ if (active) setLimits(next);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ const inputRef = useRef(null);
+
+ const abortRef = useRef(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ return () => controller.abort();
+ }, []);
+
+ const listRef = useRef(files);
+ useEffect(() => {
+ listRef.current = files;
+ }, [files]);
+
+ const mutate = useCallback(
+ (fn: (current: UploadedFileRef[]) => UploadedFileRef[]) => {
+ const next = fn(listRef.current).slice(0, maxFiles);
+ listRef.current = next;
+ onChange(multiple ? next : (next[0] ?? null));
+ },
+ [maxFiles, multiple, onChange],
+ );
+
+ const upsert = useCallback(
+ (ref: UploadedFileRef) => {
+ if (dismissedRef.current.has(ref.uploadId)) return;
+
+ mutate((current) =>
+ current.some((entry) => entry.uploadId === ref.uploadId)
+ ? current.map((entry) => (entry.uploadId === ref.uploadId ? ref : entry))
+ : [...current, ref],
+ );
+ },
+ [mutate],
+ );
+
+ const queueRef = useRef([]);
+ const pumpingRef = useRef(false);
+ const keyRef = useRef(0);
+ const [queuedCount, setQueuedCount] = useState(0);
+ const inFlightCountRef = useRef(0);
+
+ const dismissedRef = useRef>(new Set());
+
+ const track = useCallback(
+ async (started: UploadedFileRef) => {
+ const outcome = await waitForUpload(started, {
+ ...(abortRef.current?.signal ? { signal: abortRef.current.signal } : {}),
+ onUpdate: (next) => upsert(next),
+ });
+
+ if (dismissedRef.current.has(started.uploadId)) return;
+
+ if (outcome.ref.status === "failed") {
+ setMessage(`“${outcome.ref.name}” — ${outcome.ref.error ?? "could not be processed."}`);
+ } else if (outcome.timedOut) {
+ setMessage(
+ `“${outcome.ref.name}” is taking longer than expected. It may still finish — ` +
+ `remove and re-add it if it doesn't.`,
+ );
+ }
+ },
+ [upsert],
+ );
+
+ const pump = useCallback(async () => {
+ if (pumpingRef.current) return;
+ pumpingRef.current = true;
+
+ try {
+ for (;;) {
+ const file = queueRef.current.shift();
+ if (!file) break;
+ setQueuedCount(queueRef.current.length);
+
+ const key = ++keyRef.current;
+ setInFlight((current) => [...current, { key, name: file.name, percent: 0 }]);
+
+ try {
+ const started = await startUpload({
+ formId,
+ fieldId,
+ file,
+ onProgress: (percent) =>
+ setInFlight((current) =>
+ current.map((entry) => (entry.key === key ? { ...entry, percent } : entry)),
+ ),
+ ...(abortRef.current?.signal ? { signal: abortRef.current.signal } : {}),
+ });
+
+ upsert(started);
+
+ void track(started);
+ } catch (err) {
+ setMessage(
+ err instanceof UploadError
+ ? `“${file.name}” — ${err.message}`
+ : `Something went wrong uploading “${file.name}”.`,
+ );
+ } finally {
+ setInFlight((current) => current.filter((entry) => entry.key !== key));
+ }
+ }
+ } finally {
+ pumpingRef.current = false;
+ }
+ }, [fieldId, formId, track, upsert]);
+
+ const handleFiles = useCallback(
+ (picked: FileList | null) => {
+ if (!picked || picked.length === 0 || disabled) return;
+
+ setMessage(null);
+ const reserved = listRef.current.length + queueRef.current.length + inFlightCountRef.current;
+ const room = maxFiles - reserved;
+
+ if (room <= 0) {
+ setMessage(
+ maxFiles === 1
+ ? "Remove the current file to attach a different one."
+ : `You can attach at most ${maxFiles} files.`,
+ );
+ return;
+ }
+
+ const chosen = Array.from(picked).slice(0, room);
+ if (chosen.length < picked.length) {
+ setMessage(`Only the first ${room} file${room === 1 ? "" : "s"} were added.`);
+ }
+
+ queueRef.current.push(...chosen);
+ setQueuedCount(queueRef.current.length);
+
+ if (inputRef.current) inputRef.current.value = "";
+
+ void pump();
+ },
+ [disabled, maxFiles, pump],
+ );
+
+ useEffect(() => {
+ inFlightCountRef.current = inFlight.length;
+ }, [inFlight]);
+
+ const remove = (uploadId: string) => {
+ setMessage(null);
+ dismissedRef.current.add(uploadId);
+ mutate((current) => current.filter((entry) => entry.uploadId !== uploadId));
+ };
+
+ const atCapacity = files.length + queuedCount + inFlight.length >= maxFiles;
+
+ return (
+
+ {/* ── drop zone ── */}
+ {!atCapacity && (
+
{
+ e.preventDefault();
+ if (!disabled) setDragging(true);
+ }}
+ onDragLeave={() => setDragging(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDragging(false);
+ void handleFiles(e.dataTransfer.files);
+ }}
+ className="border border-dashed transition-colors"
+ style={{
+ borderColor: dragging ? "var(--cf-orange)" : "var(--cf-line-strong)",
+ background: dragging ? "rgba(232, 122, 65, 0.06)" : "#fff",
+ }}
+ >
+
+
+
+ {placeholder ||
+ (multiple ? "Choose files or drag them here" : "Choose a file or drag it here")}
+
+
+ {[
+ options?.accept?.length ? options.accept.join(", ") : null,
+ limitLabel(options?.accept, options?.maxMb, limits),
+ multiple ? `up to ${maxFiles} files` : null,
+ ]
+ .filter(Boolean)
+ .join(" · ") || "Attach a document"}
+
+
+
+ handleFiles(e.target.files)}
+ />
+
+ )}
+
+ {/* ── transferring ── */}
+ {inFlight.map((entry) => (
+
+
+
+
+ {entry.name}
+
+
+ {entry.percent}%
+
+
+
+
+ ))}
+
+ {queuedCount > 0 && (
+
+ {queuedCount} more waiting to upload
+
+ )}
+
+ {/* ── attached ── */}
+ {files.length > 0 && (
+
+ {files.map((file) => (
+
+
+
+
+ {file.name}
+
+ {formatBytes(file.sizeBytes)} · {statusLabel(file.status)}
+
+
+
+ remove(file.uploadId)}
+ disabled={disabled}
+ className="shrink-0 cursor-pointer p-1 text-(--cf-ink-soft) transition-colors hover:text-(--cf-ink) disabled:cursor-not-allowed"
+ aria-label={`Remove ${file.name}`}
+ >
+
+
+
+ ))}
+
+ )}
+
+ {message && (
+
+
+ {message}
+
+ )}
+
+ );
+}
+
+function StatusIcon({ status }: { status: UploadedFileRef["status"] }) {
+ if (status === "ready") {
+ return ;
+ }
+ if (status === "failed") {
+ return ;
+ }
+ return ;
+}
+
+function statusLabel(status: UploadedFileRef["status"]): string {
+ switch (status) {
+ case "ready":
+ return "Attached";
+ case "failed":
+ return "Could not be attached";
+ case "processing":
+ return "Processing...";
+ default:
+ return "Uploading...";
+ }
+}
diff --git a/apps/web/components/forms/FormDraftNotice.tsx b/apps/web/components/forms/FormDraftNotice.tsx
index 489e6fb..17dc216 100644
--- a/apps/web/components/forms/FormDraftNotice.tsx
+++ b/apps/web/components/forms/FormDraftNotice.tsx
@@ -7,7 +7,6 @@ export type DraftStatus = "idle" | "saving" | "saved";
interface FormDraftNoticeProps {
status: DraftStatus;
- /** ISO timestamp of the draft that was restored, if this session resumed one. */
resumedFrom?: string | null;
onStartOver: () => void;
}
@@ -27,15 +26,6 @@ function relativeTime(iso: string): string {
return `${days} day${days === 1 ? "" : "s"} ago`;
}
-/**
- * Tells a signed-in respondent that their progress is being kept, and that a
- * previous session was picked up.
- *
- * Both halves matter. Silently restoring answers is alarming — the form appears
- * to already know things about you — so the resume is announced with a way to
- * discard it. And an autosave nobody can see is indistinguishable from no
- * autosave, which is what makes people afraid to close the tab.
- */
export function FormDraftNotice({ status, resumedFrom, onStartOver }: FormDraftNoticeProps) {
if (status === "idle" && !resumedFrom) return null;
diff --git a/apps/web/components/forms/FormErrorState.tsx b/apps/web/components/forms/FormErrorState.tsx
index 2ba88bd..13a8af4 100644
--- a/apps/web/components/forms/FormErrorState.tsx
+++ b/apps/web/components/forms/FormErrorState.tsx
@@ -13,27 +13,11 @@ interface FormErrorStateProps {
| "expired"
| "sign-in-required"
| "domain-not-allowed";
- /** Domains the form accepts, listed so the respondent knows which account to
- * use rather than guessing. */
allowedDomains?: string[];
- /** Where to send someone who needs to sign in — carries a return path so they
- * land back on the form rather than the dashboard. */
signInHref?: string;
- /** Offered when the form takes more than one response. */
onRespondAgain?: () => void;
}
-/**
- * The five states a respondent can land on instead of the form.
- *
- * Held as a lookup rather than the nested ternary chain this used to be —
- * six branches deep, it had become hard to read and easy to mis-nest.
- *
- * Copy is intentionally plain. The reference this page was restyled from
- * answers a blocked visitor with jokes ("NICE TRY, FBI.", "403: YOUR BRAIN
- * NOT FOUND."). These screens are shown to the form owner's customers and
- * candidates, who have usually done nothing wrong beyond arriving late.
- */
const STATES: Record<
FormErrorStateProps["type"],
{ eyebrow: string; title: string; body: string }
@@ -46,11 +30,6 @@ const STATES: Record<
"already-submitted": {
eyebrow: "Already submitted",
title: "You've responded to this form",
- // Deliberately not "this form accepts one response per person". That's the
- // usual reason for landing here, but the same screen also catches a
- // duplicate submit racing on one idempotency key, which can happen on a
- // form that does accept several responses — and stating a policy that isn't
- // in force is worse than not mentioning it.
body: "Thanks — we already have your response on file, so there's no need to send it again.",
},
"sign-in-required": {
@@ -118,11 +97,11 @@ export function FormErrorState({
{config.body}
- {/* Naming the accepted domains turns "wrong account" from a dead end
- into an instruction. Subdomains are covered by each entry, which is
- why one line can admit both @uni.edu and @dept.uni.edu. */}
{allowedDomains && allowedDomains.length > 0 && (
-
+
Accepted domains
{allowedDomains.map((domain) => (
@@ -160,12 +139,12 @@ export function FormErrorState({
)}
- {/* Demoted to a text link whenever the screen already offers the action
- the respondent actually came for. Two filled buttons of equal weight
- would make "Visit CanvasFlow" compete with "Sign in". */}
{hasPrimaryAction ? (
-
+
Visit CanvasFlow
diff --git a/apps/web/components/forms/FormHeader.tsx b/apps/web/components/forms/FormHeader.tsx
index a8c1c96..4d9230a 100644
--- a/apps/web/components/forms/FormHeader.tsx
+++ b/apps/web/components/forms/FormHeader.tsx
@@ -10,17 +10,6 @@ interface FormHeaderProps {
formTitle?: string;
}
-/**
- * Sticky header for the public form: brand, form code, and progress.
- *
- * Restyled from the supplied reference. The progress bar is the reference's
- * `border-2 border-current h-4` block rather than the 1px pill this used to
- * be — at 4px inside a drawn box it reads as a gauge from across the room,
- * which is the point of putting it above a one-question-at-a-time flow.
- *
- * Square corners and hard edges throughout, matching the `--hex-radius: 0`
- * rule the rest of the app follows.
- */
export function FormHeader({ progressPercent, submitted, formCode, formTitle }: FormHeaderProps) {
const pct = submitted ? 100 : progressPercent;
@@ -54,8 +43,6 @@ export function FormHeader({ progressPercent, submitted, formCode, formTitle }:
- {/* Progress. `aria-*` on the track so a screen reader gets the number
- without having to infer it from a decorative div. */}
;
totalQuestions: number;
answers: Record
;
@@ -33,55 +24,17 @@ interface FormQuestionProps {
handleFieldChange: (fieldId: string, value: any) => void;
handleNext: () => void;
handleBack: () => void;
- /* ── Branching-aware navigation ──────────────────────────────────────
- *
- * These used to be derived here: `isLast` was `index === total - 1` and
- * Back showed whenever `index > 0`. Neither survives conditional logic.
- * A branch can finish the form on question 2 of 6, and after a jump the
- * question you came from is whatever the path says, not `index - 1`. Both
- * answers now come from the resolver, which is the only thing that knows
- * the route this respondent actually took. */
isLast: boolean;
canGoBack: boolean;
- /** e.g. "Segment 2 of 3 · Your details". Absent on unsegmented forms. */
segmentLabel?: string | null;
- /** The segment's own description, shown once above its questions. */
segmentDescription?: string | null;
- /** Overrides the forward button's text. A multi-question page says "Next"
- * about a page, not a question. */
nextLabel?: string;
+ formId: string;
+ isPreview?: boolean;
}
-/* ── Restyled from the supplied reference ───────────────────────────────
- The logic is untouched — same props, same handlers, same validation path
- in the parent. What changed is the surface:
-
- · Drawn boxes instead of soft rings. The reference is all `border-2` and
- square corners with hard offset shadows; so is this app's own design
- system (`--hex-radius: 0`, `cf-panel`'s `5px 5px 0` shadow, and the
- "a drawn edge beats a soft panel" rule). The old `ring-1 rounded-lg`
- inputs were the odd ones out.
-
- · The question sits in a card with a mono `Q01` eyebrow, rather than
- floating as centred text.
-
- · Selected choices invert to solid ink instead of tinting 10% orange,
- which is both a stronger signal and legible for anyone who can't
- separate the tint from the unselected state.
-
- · TOGGLE renders as the reference's two side-by-side `yes_no` buttons
- rather than a 12px switch. In a one-question-at-a-time flow the switch
- was the smallest target on a screen with nothing else competing for
- the space.
-
- Left alone deliberately: the reference's all-caps option labels. These
- are author-written strings, and shouting them back distorts content the
- form owner wrote — `uppercase` on a sentence-case option looks like a
- bug. Only the app's own furniture is set in caps. */
-
const FIELD_BOX_STYLE: React.CSSProperties = { borderColor: "var(--cf-line-strong)" };
-/** Text-ish inputs. Square, drawn, and focus-ringed with the accent. */
const INPUT_CLS =
"w-full border bg-white px-4 h-13 text-[16px] text-(--cf-ink) placeholder:text-(--cf-ink-soft)/55 focus:outline-none focus:shadow-[3px_3px_0_0_var(--cf-line-strong)]";
@@ -102,16 +55,13 @@ export function FormQuestion({
segmentLabel,
segmentDescription,
nextLabel,
+ formId,
+ isPreview = false,
}: FormQuestionProps) {
if (fields.length === 0) return null;
return (
- /* Keyed on the page's first field rather than a position. Two different
- pages can occupy the same position on different branches, and keying on
- the number would skip the entry animation when that happens. */
- {/* Segment caption. Only rendered for segmented forms, so a plain
- linear form looks exactly as it did before. */}
{segmentLabel && (
@@ -406,10 +356,19 @@ export function FormQuestion({
)}
- {/* Two drawn buttons rather than a switch — the reference's
- `yes_no` treatment. `value` is a real boolean here, so the
- comparison is explicit to keep `undefined` from lighting up
- the "No" side before the default is applied. */}
+ {currentField.type === "FILE_UPLOAD" && (
+
handleFieldChange(currentField.id, next)}
+ disabled={isPreview}
+ />
+ )}
+
{currentField.type === "TOGGLE" && (
{[
@@ -440,8 +399,6 @@ export function FormQuestion({
);
})}
- {/* ── actions ── */}
- {/* Next is the wide one and sits right, so the thumb lands on it. */}
{canGoBack && (
void;
- /** Fields in display order, for the response summary. */
fields?: SummaryField[];
answers?: Record;
- /** The author's own note, shown in addition to the app's confirmation rather
- * than replacing it — the respondent still needs to know it landed. */
customMessage?: string | null;
- /** Offered when the form accepts more than one response. */
onRespondAgain?: () => void;
}
-/**
- * Post-submit screen.
- *
- * The response summary is lifted from the reference's "YOUR RESPONSE SUMMARY"
- * block — the one genuinely new thing in that file that needs no schema
- * changes, since the answers are already in the page's state. It gives a
- * respondent a record of what they just sent, which is the most common thing
- * people want from a confirmation screen and previously wasn't available
- * anywhere.
- *
- * The reference's social-share row and quiz breakdown are absent: sharing
- * needs a `socialLinks` column and quizzes need per-question points and
- * correct answers, none of which exist on this schema.
- */
export function FormThankYou({
siteRating,
setSiteRating,
@@ -46,19 +28,23 @@ export function FormThankYou({
customMessage,
onRespondAgain,
}: FormThankYouProps) {
- /** Render an answer of any supported field type as a readable string. */
const display = (field: SummaryField): string => {
const value = answers[field.id];
if (value === undefined || value === null || value === "") return "—";
+
+ if (field.type === "FILE_UPLOAD") {
+ const refs = (Array.isArray(value) ? value : [value]) as Array<{ name?: string }>;
+ const names = refs.map((ref) => ref?.name).filter(Boolean);
+ return names.length ? names.join(", ") : "—";
+ }
+
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (field.type === "RATING") return `${value} / 5`;
return String(value);
};
- // Only fields the respondent actually answered. Listing skipped optional
- // questions as "—" pads the summary with rows that carry no information.
const answered = fields.filter((f) => {
const v = answers[f.id];
if (typeof v === "boolean") return true;
@@ -112,12 +98,6 @@ export function FormThankYou({
Your response has been recorded. You can close this tab whenever you're ready.
- {/* The author's note, below the app's confirmation rather than instead
- of it. Whatever they wrote — "we'll email you Monday", a link to the
- next step — is additional context; the respondent still needs the
- plain fact that the submission landed, and that isn't the author's
- job to remember to say. `whitespace-pre-line` so the paragraph breaks
- they typed survive. */}
{customMessage && customMessage.trim() !== "" && (
- {/* ── what you sent ── */}
{answered.length > 0 && (
>>`, `| 0`), all of which ECMAScript specifies exactly. The obvious
- * shorthand, `Math.sin(seed) * 10000 % 1`, cannot be used here: the spec
- * lets each engine approximate `Math.sin`, so Node and the browser disagree
- * in the final bits. Scaling by 10000 and taking the fractional part
- * amplifies that into a visible difference, which React then reports as a
- * hydration mismatch on every dot in the scatter plot.
- */
const rng = (seed: number) => {
let t = (seed + 0x6d2b79f5) | 0;
t = Math.imul(t ^ (t >>> 15), 1 | t);
@@ -142,7 +131,6 @@ const rng = (seed: number) => {
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
-/** Fixed-precision rounding, so SSR and client emit the same string. */
const round = (n: number, dp: number) => {
const f = 10 ** dp;
return Math.round(n * f) / f;
@@ -163,9 +151,6 @@ const ScatterChart = ({ w = 480, h = 220 }: { w?: number; h?: number }) => {
const dx = (rng(seed++) - 0.5) * 2 * g.spread;
const dy = (rng(seed++) - 0.5) * 2 * g.spread;
dots.push({
- // Rounded so the server and the client serialise byte-identical
- // numbers, and so 118 dots don't each carry a 17-digit coordinate
- // into the HTML. Sub-pixel precision is invisible on an r=2.6 dot.
x: round(padL + (g.cx + dx) * innerW, 2),
y: round(padT + (g.cy + dy) * innerH, 2),
c: g.color,
@@ -307,8 +292,6 @@ export const DashboardMock = () => {
Live breakdown of responses across product areas, segments, and time.
- {/* Tabs scroll rather than wrap. Wrapping a tab strip onto a second
- line reads as broken chrome; sliding it is what real UI does. */}
{
>
{k.l}
- {/* The delta is the first thing to go: three of these side by
- side at phone width leaves no room for a second line of
- supporting text under each figure. */}
{
))}
- {/* Two charts side by side gives each about 150px on a phone, which
- is narrower than their own axis labels. They stack instead. */}
Responses by form · Q1–Q3
@@ -454,7 +432,6 @@ export const FormBuilderMock = () => {
Draft
- {/* Redundant next to "Draft" once space is tight. */}
{
- {/* The block palette is a 160px fixed column. Below sm it becomes a
- horizontal strip above the canvas, so the canvas keeps the full
- width for the questions that are the point of the mock. */}
(
className="hex-mono flex items-center gap-1.5 text-[10px]"
style={{ color: "var(--hex-ink-muted)" }}
>
- {/* Two stacked spans: the outer one pulses as a halo while the
- inner dot stays solid, so the indicator reads as a signal
- rather than the whole dot fading in and out. */}
(
- {/* Tinted pill rather than a solid fill. A saturated block of
- colour with white type fights the muted paper palette; a wash
- of the same hue keeps the category legible and calm. */}
{
))}
- {/* The stage is a fixed 420px tall on desktop. On a phone the tool
- rail and inspector are gone, so the canvas alone needs less
- height and a shorter box avoids a tall band of empty grid. */}
- {/* Tool rail: 56px of icons with no labels. Dropping it below sm
- gives the canvas the room, and nothing it conveys is lost. */}
{[1, 2, 3, 4, 5].map((i) => (
{
- {/* Properties panel: 272px fixed. Kept off until md — at sm the
- rail and this panel together would leave the canvas narrower
- than the field cards it is meant to be showing. */}
diff --git a/apps/web/components/ui/CustomSelect.tsx b/apps/web/components/ui/CustomSelect.tsx
new file mode 100644
index 0000000..6d3fb81
--- /dev/null
+++ b/apps/web/components/ui/CustomSelect.tsx
@@ -0,0 +1,113 @@
+import * as React from "react";
+import { ChevronDown } from "lucide-react";
+import { cn } from "~/lib/utils";
+
+export interface CustomSelectOption {
+ value: string | number;
+ label: string;
+}
+
+export interface CustomSelectProps {
+ value: string | number;
+ onChange: (value: any) => void;
+ options: CustomSelectOption[];
+ placeholder?: string;
+ className?: string;
+ openDirection?: "up" | "down";
+}
+
+export function CustomSelect({
+ value,
+ onChange,
+ options,
+ placeholder = "Select...",
+ className,
+ openDirection = "down",
+}: CustomSelectProps) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const containerRef = React.useRef
(null);
+
+ const selectedOption = options.find((opt) => String(opt.value) === String(value));
+ const displayLabel = selectedOption ? selectedOption.label : placeholder;
+
+ React.useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ }
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ }
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isOpen]);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ className="flex w-full items-center justify-between border px-3 text-[13px] transition-all"
+ style={{
+ background: "var(--cf-cream-2)",
+ borderColor: "var(--cf-line-strong)",
+ height: "36px",
+ textAlign: "left",
+ boxShadow: "2px 2px 0 0 rgba(26, 29, 41, 0.04)",
+ }}
+ >
+ {displayLabel}
+
+
+
+ {isOpen && (
+
+ {options.map((option) => {
+ const isSelected = String(option.value) === String(value);
+ return (
+ {
+ onChange(option.value);
+ setIsOpen(false);
+ }}
+ className="flex w-full items-center px-3 py-2 text-left text-[13px] hover:bg-(--cf-ink-soft)/5 transition-colors"
+ style={{
+ color: isSelected ? "var(--cf-orange)" : "var(--cf-ink)",
+ fontWeight: isSelected ? 600 : 400,
+ background: isSelected ? "var(--cf-cream)" : "transparent",
+ }}
+ >
+ {option.label}
+ {isSelected && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/ui/ModalOverlay.tsx b/apps/web/components/ui/ModalOverlay.tsx
index 07bff57..838a1e3 100644
--- a/apps/web/components/ui/ModalOverlay.tsx
+++ b/apps/web/components/ui/ModalOverlay.tsx
@@ -1,50 +1,42 @@
"use client";
-import React from "react";
+import React, { useEffect } from "react";
import { createPortal } from "react-dom";
-/**
- * Full-page modal backdrop that actually covers the whole page.
- *
- * The `.cf-scrim` class alone cannot do this from inside a dashboard page.
- * `app/dashboard/layout.tsx` wraps page content in ``, and a positioned element with a z-index establishes a stacking
- * context — so a scrim rendered inside `` has its `z-index: 50` resolved
- * *within* that z-10 context. `DashboardNav` is a sibling of `` at z-40,
- * which means it outranks the entire subtree no matter how high the scrim
- * climbs. Raising the number is not a fix; the scrim has to leave the subtree.
- *
- * Hence the portal to `document.body`, where the z-index competes at the top
- * level and the nav is covered like everything else.
- *
- * Dialogs rendered from the layout itself (create form) sit outside ``
- * already and never had this problem. The delete-form dialog on the sketches
- * page does have it, and can be moved onto this component the same way.
- *
- * No mount guard is needed: callers render this only in response to a click,
- * so it is never reached during SSR.
- */
export function ModalOverlay({
onDismiss,
children,
}: {
- /** Click-outside handler. Omit to make the backdrop inert. */
onDismiss?: () => void;
children: React.ReactNode;
}) {
+ useEffect(() => {
+ const previous = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => {
+ document.body.style.overflow = previous;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!onDismiss) return;
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onDismiss();
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [onDismiss]);
+
return createPortal(
e.target === e.currentTarget && onDismiss() : undefined}
>
{children}
diff --git a/apps/web/env.js b/apps/web/env.js
index 161c7f0..ff94500 100644
--- a/apps/web/env.js
+++ b/apps/web/env.js
@@ -2,36 +2,13 @@ import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
- /**
- * Specify your server-side environment variables schema here. This way you can ensure the app
- * isn't built with invalid env vars.
- */
server: {},
-
- /**
- * Specify your client-side environment variables schema here. This way you can ensure the app
- * isn't built with invalid env vars. To expose them to the client, prefix them with
- * `NEXT_PUBLIC_`.
- */
client: {
NEXT_PUBLIC_API_URL: z.string().optional(),
},
-
- /**
- * You can't destruct `process.env` as a regular object in the Next.js edge runtimes (e.g.
- * middlewares) or client-side so we need to destruct manually.
- */
runtimeEnv: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
- /**
- * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially
- * useful for Docker builds.
- */
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
- /**
- * Makes it so that empty strings are treated as undefined. `SOME_VAR: z.string()` and
- * `SOME_VAR=''` will throw an error.
- */
emptyStringAsUndefined: true,
});
diff --git a/apps/web/hooks/api/analytics/index.ts b/apps/web/hooks/api/analytics/index.ts
index 2c9446a..0438fed 100644
--- a/apps/web/hooks/api/analytics/index.ts
+++ b/apps/web/hooks/api/analytics/index.ts
@@ -1,16 +1,5 @@
import { trpc } from "~/trpc/client";
-/**
- * Fetches all summary analytics metrics for a single form.
- *
- * Returns exactly: totalResponses, deviceBreakdown, dailyTrends (30d),
- * peakDay, avgSubmissionsPerDay, avgSubmissionsPerWeek.
- *
- * `deviceBreakdown` counts submissions by device, not page views — form view
- * tracking was removed, so there is no viewer-side denominator here. Don't
- * reintroduce a completion rate on this hook without a new source for
- * "people who started but didn't submit".
- */
export const useGetFormAnalytics = (formId: string) => {
const {
data: analytics,
@@ -23,14 +12,6 @@ export const useGetFormAnalytics = (formId: string) => {
{ formId },
{
enabled: !!formId && formId.length === 36,
- // Analytics for a published form changes only when new
- // submissions arrive. 30s freshness gives back-nav within
- // the dashboard near-instant feel while still picking up
- // fresh responses on the next visit. Submission mutations
- // do not invalidate this key (they come from public form
- // visitors, not the studio user), so this is also the
- // upper bound on UI staleness — adjust if you wire up a
- // live channel later.
staleTime: 30_000,
refetchOnWindowFocus: false,
},
@@ -39,18 +20,6 @@ export const useGetFormAnalytics = (formId: string) => {
return { analytics, error, isLoading, isError, isSuccess, refetch };
};
-/**
- * Cursor-paginated submissions for the analytics table.
- *
- * Each page returns up to `limit` rows older than the cursor (which is
- * the createdAt ISO of the last row from the previous page). React Query
- * concatenates pages internally; we flatten them into a single array for
- * the consumer.
- *
- * Polling: refetches every 30s while the tab is visible. With infinite
- * queries this refetches ALL loaded pages — fine for typical sizes,
- * worth watching if a user loads thousands of rows.
- */
export const useGetSubmissions = (formId: string) => {
const PAGE_SIZE = 100;
const enabled = !!formId && formId.length === 36;
@@ -70,7 +39,6 @@ export const useGetSubmissions = (formId: string) => {
},
);
- // Flatten the pages so the consumer sees a single submissions list.
const submissions = result.data?.pages.flatMap((p) => p.submissions) ?? [];
return {
@@ -86,10 +54,6 @@ export const useGetSubmissions = (formId: string) => {
};
};
-/**
- * Records that a visitor answered a field and clicked Next.
- * Called on each Next click in the public form page.
- */
export const useRecordFieldAnswer = () => {
const {
mutate: recordFieldAnswer,
@@ -100,13 +64,6 @@ export const useRecordFieldAnswer = () => {
return { recordFieldAnswer, recordFieldAnswerAsync, isPending };
};
-/**
- * The heavier breakdown: per-question distribution, day-of-week, 30/60/90d
- * trend totals, response velocity.
- *
- * Split from the summary query because it's the expensive one, not because it
- * was ever gated — it's available to anyone who can see the form.
- */
export const useGetDetailedAnalytics = (formId: string) => {
const {
data: detailedAnalytics,
diff --git a/apps/web/hooks/api/feedback/index.ts b/apps/web/hooks/api/feedback/index.ts
index 9a620a7..0e7c8f9 100644
--- a/apps/web/hooks/api/feedback/index.ts
+++ b/apps/web/hooks/api/feedback/index.ts
@@ -2,14 +2,6 @@ import { trpc } from "~/trpc/client";
export type FeedbackType = "bug" | "feedback" | "complaint" | "feature_request";
-/**
- * Files a report from the feedback widget.
- *
- * Note the absence of any identity argument: the server reads the session
- * cookie itself, so there is nothing here that could attribute a report to the
- * wrong account. Nothing to invalidate either — reports aren't read back
- * anywhere in the app yet.
- */
export const useSubmitFeedback = () => {
const {
mutateAsync: submitFeedbackAsync,
diff --git a/apps/web/hooks/api/form/index.ts b/apps/web/hooks/api/form/index.ts
index b83614c..8297532 100644
--- a/apps/web/hooks/api/form/index.ts
+++ b/apps/web/hooks/api/form/index.ts
@@ -14,8 +14,6 @@ export const useCreateForm = () => {
isSuccess,
status,
} = trpc.form.createForm.useMutation({
- // Optimistic update: add a placeholder form immediately so the UI
- // feels instant. The server response replaces it on success.
onMutate: async (input) => {
await utils.form.listFormsByUserId.cancel();
const previous = utils.form.listFormsByUserId.getData();
@@ -83,11 +81,6 @@ export const useListFormsByUserId = () => {
isLoading,
refetch,
} = trpc.form.listFormsByUserId.useQuery(undefined, {
- // My Forms list is read on the sketches page and the analytics
- // sidebar; both pages get hit on back-nav. Same caching policy
- // as the dashboard — 60s freshness, no refetch-on-focus.
- // Mutations (create/update/delete form) already invalidate this
- // key, so stale data can't survive a real change.
staleTime: 60_000,
refetchOnWindowFocus: false,
});
@@ -119,19 +112,13 @@ export const useCreateFormField = () => {
status,
} = trpc.form.createFormField.useMutation({
onMutate: async (input) => {
- // Cancel any in-flight refetches for this form's fields
await utils.form.listFormFields.cancel({ formId: input.formId });
const previous = utils.form.listFormFields.getData({ formId: input.formId });
- // Optimistically add the new field so the canvas updates instantly
utils.form.listFormFields.setData({ formId: input.formId }, (old) => {
const optimistic = {
id: `optimistic-${Date.now()}`,
formId: input.formId,
- // Carried through from the request rather than defaulted. Dropping it
- // put the optimistic row outside every segment, so a form filtered to
- // one segment showed nothing appear until the server reply landed and
- // the cache was replaced.
segmentId: input.segmentId ?? null,
label: input.label ?? "",
labelKey: input.label?.toLowerCase().replace(/[^a-z0-9]+/g, "-") ?? "field",
@@ -141,8 +128,6 @@ export const useCreateFormField = () => {
type: input.type,
options: input.options ?? null,
description: input.description ?? null,
- // Optimistic baseline — the real version is set when
- // the server reply lands and the cache is refreshed.
version: 0,
createdAt: new Date(),
updatedAt: new Date(),
@@ -158,7 +143,6 @@ export const useCreateFormField = () => {
}
},
onSettled: (_data, _err, input) => {
- // Only refetch fields for this specific form — not the entire router
void utils.form.listFormFields.invalidate({ formId: input.formId });
},
});
diff --git a/apps/web/hooks/api/user/index.ts b/apps/web/hooks/api/user/index.ts
index 83570bb..354d090 100644
--- a/apps/web/hooks/api/user/index.ts
+++ b/apps/web/hooks/api/user/index.ts
@@ -1,6 +1,5 @@
import { trpc } from "~/trpc/client";
-/** Fetches the current user's profile. */
export const useGetMe = () => {
const { data, isLoading, error } = trpc.user.getMe.useQuery(undefined, {
staleTime: 5 * 60 * 1000,
@@ -14,13 +13,6 @@ export const useGetMe = () => {
};
};
-/**
- * Updates the signed-in user's display name and/or avatar preset.
- *
- * Both `getMe` caches are refreshed from the mutation's own return value
- * rather than by invalidating and refetching: the procedure returns the full
- * updated user, so a round trip would only re-fetch what we already hold.
- */
export const useUpdateMe = () => {
const utils = trpc.useUtils();
diff --git a/apps/web/hooks/use-toasts.ts b/apps/web/hooks/use-toasts.ts
index 37bd2c4..e429b3c 100644
--- a/apps/web/hooks/use-toasts.ts
+++ b/apps/web/hooks/use-toasts.ts
@@ -1,8 +1,5 @@
import * as React from "react";
-// This app ships `sonner` rather than the Radix toast primitives, so there is
-// no `components/ui/toast` to import from. The shapes this hook relies on are
-// declared locally instead.
type ToastProps = {
variant?: "default" | "destructive";
open?: boolean;
@@ -30,8 +27,6 @@ function genId() {
return count.toString();
}
-// The reducer only ever needs the action names as types, so this is declared
-// as a type rather than a runtime object.
type ActionType = {
ADD_TOAST: "ADD_TOAST";
UPDATE_TOAST: "UPDATE_TOAST";
@@ -96,8 +91,6 @@ export const reducer = (state: State, action: Action): State => {
case "DISMISS_TOAST": {
const { toastId } = action;
- // ! Side effects ! - This could be extracted into a dismissToast() action,
- // but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
diff --git a/apps/web/hooks/useAnimatedCounter.ts b/apps/web/hooks/useAnimatedCounter.ts
index 17cd306..de536ef 100644
--- a/apps/web/hooks/useAnimatedCounter.ts
+++ b/apps/web/hooks/useAnimatedCounter.ts
@@ -10,7 +10,6 @@ export function useAnimatedCounter(target: number, duration = 1500, start = fals
const step = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
- // ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.floor(eased * target));
if (progress < 1) raf = requestAnimationFrame(step);
diff --git a/apps/web/hooks/useDebounce.ts b/apps/web/hooks/useDebounce.ts
index aa9ab2c..7df59bd 100644
--- a/apps/web/hooks/useDebounce.ts
+++ b/apps/web/hooks/useDebounce.ts
@@ -1,16 +1,5 @@
import { useEffect, useRef, useState } from "react";
-/**
- * Returns a value that updates only after `delay` ms have passed without
- * `value` changing. Useful for debouncing typing into a search input so
- * downstream filtering / network calls / heavy effects only run once the
- * user pauses.
- *
- * Example:
- * const [query, setQuery] = useState("")
- * const debouncedQuery = useDebounce(query, 200)
- * // expensive operation reads `debouncedQuery`, controlled input reads `query`
- */
export function useDebounce
(value: T, delay = 200): T {
const [debounced, setDebounced] = useState(value);
@@ -22,14 +11,6 @@ export function useDebounce(value: T, delay = 200): T {
return debounced;
}
-/**
- * Returns a stable callback that defers its invocation until `delay` ms
- * have passed without another call. Use when the side effect needs to run
- * with the latest arguments rather than re-deriving from state.
- *
- * The internal timer is cleared on unmount so a pending call won't fire
- * after the component goes away.
- */
export function useDebouncedCallback(
callback: (...args: TArgs) => void,
delay = 200,
@@ -37,8 +18,6 @@ export function useDebouncedCallback(
const callbackRef = useRef(callback);
const timerRef = useRef(null);
- // Always invoke the latest version of the callback without forcing
- // consumers to memo it.
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
diff --git a/apps/web/hooks/useDebouncedCallback.ts b/apps/web/hooks/useDebouncedCallback.ts
index 28561e4..5bba2d6 100644
--- a/apps/web/hooks/useDebouncedCallback.ts
+++ b/apps/web/hooks/useDebouncedCallback.ts
@@ -2,24 +2,6 @@
import { useCallback, useEffect, useRef } from "react";
-/**
- * Defer a call until the caller stops calling it for `delay` ms.
- *
- * Used for the respondent's autosave and answer tracking, where the alternative
- * is a request per keystroke. The two properties that matter here beyond plain
- * debouncing:
- *
- * · The latest arguments win. A pending call is replaced, not queued, so the
- * request that eventually goes out carries the newest answers rather than
- * whatever was typed 800ms ago.
- * · `flush()` fires the pending call immediately. Without it, closing the tab
- * or hitting Submit mid-debounce would silently drop the last edit — exactly
- * the edit the respondent most expects to have been saved.
- *
- * The callback is held in a ref so a fresh closure each render doesn't restart
- * the timer; a debounce that resets whenever React re-renders never fires while
- * someone is actively typing.
- */
export function useDebouncedCallback(
callback: (...args: Args) => void,
delay: number,
@@ -69,8 +51,6 @@ export function useDebouncedCallback(
pendingArgsRef.current = null;
}, [clear]);
- // Fire whatever is pending on unmount rather than dropping it. Navigating
- // away mid-debounce is the common way to lose the last answer.
useEffect(() => {
return () => {
const pending = pendingArgsRef.current;
diff --git a/apps/web/lib/form-access.ts b/apps/web/lib/form-access.ts
index 1ed968d..8e68c70 100644
--- a/apps/web/lib/form-access.ts
+++ b/apps/web/lib/form-access.ts
@@ -1,25 +1,3 @@
-/**
- * Who is allowed to answer a form — the respondent's side of it.
- *
- * A deliberate mirror of `packages/services/form-submission/access.ts`, which
- * remains the enforcing copy. This exists because the public form has to answer
- * a question *before* anyone submits: "should this person be shown the form, or
- * a sign-in wall, or a wrong-domain notice?" Asking somebody fifty questions
- * and only then telling them to sign in wastes their time and loses their
- * answers.
- *
- * It's duplicated rather than imported because `apps/web` depends on
- * `@repo/trpc` alone; the services package is server-side and pulls in the
- * database client, which has no business in a browser bundle. Same reasoning
- * and same shape as `form-flow.ts` and `form-logic.ts`.
- *
- * Only the pure predicates live here. `assertRespondentAllowed` stays on the
- * server, because a gate the client can compute is a hint, not a rule — these
- * functions decide what to *show*, never what to *allow*. If the two ever
- * disagree the server wins and the respondent sees the matching error screen.
- */
-
-/** The access-controlling settings, as stored on the form. */
export interface FormAccessRules {
requireSignIn: boolean;
collectRespondentEmail: boolean;
@@ -27,14 +5,6 @@ export interface FormAccessRules {
allowedEmailDomains?: string[] | null;
}
-/**
- * Does the form need a signed-in respondent?
- *
- * The three dependent settings each imply it. Recording an email, holding
- * someone to one response, and restricting by domain are all impossible without
- * an account to read, so any of them turns the requirement on whether or not
- * the author ticked it.
- */
export function requiresSignIn(rules: FormAccessRules): boolean {
return (
rules.requireSignIn ||
@@ -44,17 +14,16 @@ export function requiresSignIn(rules: FormAccessRules): boolean {
);
}
-/** `dittya@nst.rishihood.edu.in` -> `nst.rishihood.edu.in` */
export function emailDomain(email: string): string {
const at = email.lastIndexOf("@");
- return at === -1 ? "" : email.slice(at + 1).trim().toLowerCase();
+ return at === -1
+ ? ""
+ : email
+ .slice(at + 1)
+ .trim()
+ .toLowerCase();
}
-/**
- * Normalise an author-supplied domain. Accepts what people actually type —
- * `@example.com`, `https://example.com`, `Example.COM ` — because rejecting
- * those would just mean a restriction that silently matches nothing.
- */
export function normaliseDomain(input: string): string {
return input
.trim()
@@ -65,18 +34,6 @@ export function normaliseDomain(input: string): string {
.replace(/^\.+|\.+$/g, "");
}
-/**
- * Is this email inside one of the allowed domains?
- *
- * Suffix matching, deliberately: an entry of `rishihood.edu.in` admits both
- * `kamlesh@rishihood.edu.in` and `dittya@nst.rishihood.edu.in`, because a
- * university that hands out per-department subdomains is still one institution
- * and the author should not have to enumerate them.
- *
- * The boundary check is what keeps that from being too loose. Matching on a
- * bare `endsWith` would also admit `evil-rishihood.edu.in`, so a longer domain
- * only matches when the extra part ends at a dot.
- */
export function isEmailDomainAllowed(email: string, allowed?: string[] | null): boolean {
if (!allowed || allowed.length === 0) return true;
diff --git a/apps/web/lib/form-flow.ts b/apps/web/lib/form-flow.ts
index 4f80e3e..3629724 100644
--- a/apps/web/lib/form-flow.ts
+++ b/apps/web/lib/form-flow.ts
@@ -1,40 +1,5 @@
-/**
- * Form traversal: which question comes next.
- *
- * Without branching this was `currentQuestionIndex + 1`. With segments and
- * conditional rules the order is no longer a property of the form alone — it
- * depends on what the respondent has answered so far. Everything here is pure
- * so the renderer stays a thin shell over it, the builder can describe and
- * lint rules with the same code the renderer obeys, and the whole thing can be
- * exercised without a browser.
- *
- * Three ideas carry the module:
- *
- * 1. A form is a flat list of questions ordered by (segment, question).
- * Segments are groups, not a second traversal layer — "jump to segment 3"
- * is really "jump to the first question of segment 3", which keeps one
- * code path instead of two.
- *
- * 2. A rule is an if/else attached to the question that triggers it. Its
- * conditions can read *any* question's answer, combined with ALL or ANY.
- * When they hold it takes its `action`; when they don't it takes
- * `elseAction` if it has one, and otherwise stands aside for the next rule.
- *
- * 3. Rules are checked in `index` order and the first decision wins. No
- * decision means fall through to the next question in the flat list, which
- * is what makes an un-branched form behave exactly as it did before.
- */
-
import { parseIndex } from "./fractional-index";
-/* ─── Shapes ──────────────────────────────────────────────────────────────
- *
- * Structural types rather than imports from the server models: this module
- * only needs the properties it actually reads, and staying structural means
- * the builder's in-progress local rows (temp ids, no timestamps) satisfy it
- * too.
- */
-
export interface FlowField {
id: string;
segmentId?: string | null;
@@ -70,18 +35,14 @@ export type LogicMatch = "ALL" | "ANY";
export type LogicAction = "JUMP_TO_FIELD" | "JUMP_TO_SEGMENT" | "SUBMIT" | "CONTINUE";
-/** Operators that test presence and take no operand. */
export const VALUELESS_OPERATORS: readonly LogicOperator[] = ["IS_EMPTY", "IS_NOT_EMPTY"];
-/** Operators whose operand is a list of choices. */
export const MULTI_VALUE_OPERATORS: readonly LogicOperator[] = ["IS_ANY_OF", "IS_NONE_OF"];
-/** Actions that carry no destination. */
export const TARGETLESS_ACTIONS: readonly LogicAction[] = ["SUBMIT", "CONTINUE"];
export interface FlowCondition {
id: string;
- /** The question whose answer this condition reads. Any question in the form. */
fieldId: string;
operator: LogicOperator;
value?: unknown;
@@ -90,7 +51,6 @@ export interface FlowCondition {
export interface FlowRule {
id: string;
- /** The question after which this rule is evaluated. */
fieldId: string;
match: LogicMatch;
conditions: FlowCondition[];
@@ -108,22 +68,13 @@ export interface FlowRule {
export type Answers = Record;
-/** Where the respondent goes next. `end` covers both "ran out of questions"
- * and "a branch said finish here". */
export type NextStep = { kind: "field"; fieldId: string } | { kind: "end" };
-
-/** A form flattened into traversal order, with the lookups the resolver needs.
- * Build once per form and pass it around. */
export interface Flow {
- /** Every question in traversal order. */
order: FlowField[];
- /** Position of each question id within `order`. */
positionById: Map;
fieldById: Map;
segmentById: Map;
- /** Segments in display order. Excludes the implicit segment. */
segments: FlowSegment[];
- /** Rules grouped by their trigger question, each list in `index` order. */
rulesByFieldId: Map;
}
@@ -132,19 +83,9 @@ export interface Flow {
function byIndex(a: T, b: T): number {
const d = parseIndex(a.index) - parseIndex(b.index);
if (d !== 0 && Number.isFinite(d)) return d;
- // Stable tiebreak. Two rows can share an index while a reorder is mid-save,
- // and an unstable sort there would make questions visibly swap.
return a.id.localeCompare(b.id);
}
-/**
- * Flatten a form into traversal order.
- *
- * Questions with no `segmentId` come first. They exist only on forms that were
- * never segmented (where they are the whole form) or, briefly, on a form whose
- * last segment was just deleted — in both cases "before every real segment" is
- * the intuitive place for them.
- */
export function buildFlow(
fields: readonly FlowField[],
segments: readonly FlowSegment[] = [],
@@ -161,9 +102,6 @@ export function buildFlow(
if (bucket) {
bucket.push(field);
} else {
- // Either genuinely unassigned, or assigned to a segment absent from the
- // list we were handed. Treating an unknown segment as unassigned keeps
- // the question reachable instead of silently dropping it.
unassigned.push(field);
}
}
@@ -196,13 +134,6 @@ export function buildFlow(
/* ─── Condition matching ──────────────────────────────────────────────── */
-/**
- * Whether an answer counts as "not given".
- *
- * `false` is deliberately NOT empty: an unchecked toggle is an answer, and
- * treating it as missing would make IS_EMPTY fire on a question the respondent
- * did answer. `0` is an answer for the same reason.
- */
export function isAnswerEmpty(answer: unknown): boolean {
if (answer === undefined || answer === null) return true;
if (typeof answer === "string") return answer.trim() === "";
@@ -216,9 +147,6 @@ function toBoolean(v: unknown): boolean {
return s === "true" || s === "yes" || s === "1";
}
-/** Case- and whitespace-insensitive scalar comparison. Choice labels are free
- * text typed twice — once on the question, once on the condition — so exact
- * matching would fail on invisible differences. */
function scalarEquals(a: unknown, b: unknown): boolean {
if (typeof a === "boolean" || typeof b === "boolean") return toBoolean(a) === toBoolean(b);
return String(a).trim().toLowerCase() === String(b).trim().toLowerCase();
@@ -228,9 +156,6 @@ function normalise(v: unknown): string {
return String(v).trim().toLowerCase();
}
-/** Numeric comparison that also understands dates, so GREATER_THAN works on
- * DATE/DATETIME answers. Returns null when either side isn't comparable —
- * callers treat that as "no match" rather than guessing an ordering. */
function compareOrdered(answer: unknown, value: unknown): number | null {
const a = Number(answer);
const b = Number(value);
@@ -243,22 +168,12 @@ function compareOrdered(answer: unknown, value: unknown): number | null {
return null;
}
-/** The operand as a list, for IS_ANY_OF / IS_NONE_OF. Tolerates a single
- * scalar so a rule authored before the operator changed still behaves. */
function asList(value: unknown): unknown[] {
if (Array.isArray(value)) return value;
if (value === undefined || value === null || value === "") return [];
return [value];
}
-/**
- * Does one condition hold for the given answer?
- *
- * Multi-select answers arrive as arrays, which is why EQUALS has an
- * array-specific reading: "is X" on a checkbox question means X is the only
- * thing selected. An author who means "X is among the selections" reaches for
- * `contains`, and the two stay distinguishable.
- */
export function matchesCondition(condition: FlowCondition, answer: unknown): boolean {
const empty = isAnswerEmpty(answer);
@@ -271,9 +186,6 @@ export function matchesCondition(condition: FlowCondition, answer: unknown): boo
break;
}
- // Every remaining operator compares against something. An unanswered
- // question matches none of them — except the negative ones, where "no
- // answer" genuinely is "not equal to X".
if (empty) {
return (
condition.operator === "NOT_EQUALS" ||
@@ -336,21 +248,10 @@ export function matchesCondition(condition: FlowCondition, answer: unknown): boo
}
default:
- // Unknown operator — an older client meeting a newer rule. Not matching
- // is the safe reading: the respondent continues linearly rather than
- // being sent somewhere arbitrary.
return false;
}
}
-/**
- * Does a rule's condition set hold?
- *
- * A rule with no conditions returns false, never true. It's an incomplete rule
- * the author hasn't finished, and the safe reading of "no tests" is "does not
- * apply" — treating it as vacuously true would silently turn it into an
- * unconditional jump and reroute the whole form.
- */
export function ruleMatches(rule: FlowRule, answers: Answers): boolean {
if (rule.conditions.length === 0) return false;
@@ -361,11 +262,6 @@ export function ruleMatches(rule: FlowRule, answers: Answers): boolean {
return rule.match === "ANY" ? results.some(Boolean) : results.every(Boolean);
}
-/* ─── Resolution ──────────────────────────────────────────────────────── */
-
-/** The first question of a segment, skipping empty segments so a jump to a
- * segment with no questions yet lands on the next real one instead of
- * dead-ending. */
function firstFieldOfSegment(flow: Flow, segmentId: string): string | null {
const start = flow.segments.findIndex((s) => s.id === segmentId);
if (start === -1) return null;
@@ -379,22 +275,12 @@ function firstFieldOfSegment(flow: Flow, segmentId: string): string | null {
return null;
}
-/** The next question in plain document order. */
function linearNext(flow: Flow, fromFieldId: string): string | null {
const position = flow.positionById.get(fromFieldId);
if (position === undefined) return null;
return flow.order[position + 1]?.id ?? null;
}
-/**
- * Where does the respondent go after answering `fromFieldId`?
- *
- * `visited` is the path taken so far and exists to break cycles. Rules can
- * describe a loop no single-row constraint can catch (A jumps to B, B jumps
- * back to A); the database rejects the one-hop case and this rejects the rest.
- * Landing on an already-visited question ends the form rather than trapping
- * the respondent — a wrong ending is recoverable, an infinite loop is not.
- */
export function resolveNextStep(args: {
flow: Flow;
answers: Answers;
@@ -409,8 +295,6 @@ export function resolveNextStep(args: {
return guardStep(flow, linearNext(flow, fromFieldId), visited);
}
-/** Shared by the question-level and page-level walks so there is exactly one
- * implementation of "is this destination usable". */
function guardStep(
flow: Flow,
fieldId: string | null | undefined,
@@ -422,15 +306,6 @@ function guardStep(
return { kind: "field", fieldId };
}
-/**
- * The decision a question's rules reach, or `null` when none of them decides.
- *
- * Split out from `resolveNextStep` because the page-level walk needs to tell
- * "a rule sent them somewhere" apart from "nothing matched, carry on" — the
- * two are the same outcome for one question at a time, but on a multi-question
- * page only the first is a reason to leave. Keeping it as one function means
- * the two layouts can't drift on which rule wins.
- */
export function resolveRuleDecision(args: {
flow: Flow;
answers: Answers;
@@ -439,8 +314,6 @@ export function resolveRuleDecision(args: {
}): NextStep | null {
const { flow, answers, fromFieldId, visited = [] } = args;
- /** Turn one side of a rule into a step. `null` means the side declined to
- * decide, so the next rule gets a turn. */
const applySide = (
action: LogicAction | null | undefined,
targetFieldId: string | null | undefined,
@@ -460,7 +333,6 @@ export function resolveRuleDecision(args: {
visited,
);
default:
- // No action set, or one from a newer schema this client doesn't know.
return null;
}
};
@@ -472,8 +344,6 @@ export function resolveRuleDecision(args: {
continue;
}
- // Conditions failed. A rule with an otherwise-branch is a fork and decides
- // here; a rule without one is a guard clause and stands aside.
if (rule.elseAction) {
const step = applySide(rule.elseAction, rule.elseTargetFieldId, rule.elseTargetSegmentId);
if (step) return step;
@@ -483,22 +353,11 @@ export function resolveRuleDecision(args: {
return null;
}
-/** The question a respondent starts on. */
export function resolveFirstStep(flow: Flow): NextStep {
const first = flow.order[0];
return first ? { kind: "field", fieldId: first.id } : { kind: "end" };
}
-/**
- * How many questions are still ahead, following current answers where they
- * exist and the linear fallback where they don't.
- *
- * Only used for the progress bar, which is why an estimate is acceptable:
- * with branching there is no single "total questions" to divide by, and a bar
- * that shifts as branches resolve beats one measured against a total the
- * respondent will never reach. Bounded by the question count, which also
- * bounds the walk.
- */
export function estimateRemaining(args: {
flow: Flow;
answers: Answers;
@@ -522,15 +381,6 @@ export function estimateRemaining(args: {
return remaining;
}
-/**
- * The answers that belong to a completed run.
- *
- * A respondent who answers a question, goes back, and takes a different branch
- * leaves an answer behind for a question no longer on their path. Submitting it
- * would record a response to a question they were never asked, quietly
- * corrupting every per-question analytic. Only answers for questions actually
- * visited are kept.
- */
export function answersOnPath(
answers: Answers,
visitedFieldIds: readonly string[],
@@ -548,10 +398,6 @@ export function answersOnPath(
return result;
}
-/**
- * Position of a question's segment for the "Segment 2 of 3" caption.
- * Returns null for the implicit segment, which has no title to show.
- */
export function segmentProgress(
flow: Flow,
fieldId: string,
@@ -568,75 +414,25 @@ export function segmentProgress(
return { segment, position: position + 1, total: flow.segments.length };
}
-/* ─── Pages ───────────────────────────────────────────────────────────────
- *
- * Everything above answers "which question comes next". A respondent, though,
- * sees a *page* at a time, and a page may hold one question or twenty. These
- * turn the question-level flow into the page-level one the renderer walks,
- * without duplicating any of the routing rules — a page's next step is still
- * decided by the same rules, just asked once per page instead of once per
- * question.
- */
-
-/** What the author picked. AUTO defers to the form's shape. */
export type QuestionLayout = "AUTO" | "ONE_PER_PAGE" | "SEGMENT_PER_PAGE" | "ALL_AT_ONCE";
-/** What AUTO actually became. The renderer only ever deals in these. */
export type ResolvedLayout = "ONE_PER_PAGE" | "SEGMENT_PER_PAGE" | "ALL_AT_ONCE";
export interface FlowPage {
- /** Stable across renders: the segment id, or a synthetic id for the pages
- * that don't correspond to one. */
id: string;
- /** Present when the page is a segment, so the renderer can show its title. */
segment?: FlowSegment;
fieldIds: string[];
}
-/**
- * Can this form be shown on a single page?
- *
- * No, once it branches or once it has been split into segments.
- *
- * Branching is the hard one. A rule decides what comes *next*, which only means
- * anything if there is a next page to decide. Put every question on one page and
- * the routing has nowhere to apply: `buildPages` recomputes from the current
- * answers, so questions would appear and disappear under the respondent's cursor
- * as they filled the form in, and a branch that jumps forward would silently
- * delete the questions it skipped from a page already on screen.
- *
- * Segments are the softer one, and it's about intent. Splitting a form into
- * segments is an instruction to paginate it; flattening it back into one scroll
- * throws away both that instruction and the segment headings, which have nowhere
- * to render on a page that has no segment of its own.
- */
export function canRenderOnOnePage(segmentCount: number, ruleCount: number): boolean {
return ruleCount === 0 && segmentCount <= 1;
}
-/**
- * Turn the author's setting into a concrete layout.
- *
- * AUTO reads the form's shape rather than defaulting to a fixed mode. A form
- * with no segments — or one — is a single run of questions and reads best one
- * at a time. A form with several segments has already been divided into pages
- * by its author, and showing those pages is what dividing it was for. So
- * adding a second segment switches the form to page-per-segment on its own,
- * which is the behaviour an author expects without having to find a setting.
- *
- * ALL_AT_ONCE is overridden rather than trusted, because the setting and the
- * thing that invalidates it are edited independently: an author can choose one
- * page for a flat form and add branching to it a week later. Enforcing the rule
- * here means every consumer gets a layout that actually works, instead of each
- * one having to remember to check. The stored setting is deliberately left
- * alone, so removing the branching brings the author's choice back.
- */
export function resolveLayout(
layout: QuestionLayout | undefined,
segmentCount: number,
ruleCount = 0,
): ResolvedLayout {
- /** What the form's own shape asks for. Also the fallback. */
const paged: ResolvedLayout = segmentCount > 1 ? "SEGMENT_PER_PAGE" : "ONE_PER_PAGE";
if (!layout || layout === "AUTO") return paged;
@@ -645,14 +441,6 @@ export function resolveLayout(
return layout;
}
-/**
- * Group a flow's questions into pages.
- *
- * ALL_AT_ONCE takes `answers` because with branching on, "everything" means
- * everything the respondent's answers actually lead to — a question on a road
- * not taken has no business being on the page. With no rules this collapses to
- * the full list, which is the plain Google-Forms reading.
- */
export function buildPages(flow: Flow, layout: ResolvedLayout, answers: Answers = {}): FlowPage[] {
if (flow.order.length === 0) return [];
@@ -664,9 +452,6 @@ export function buildPages(flow: Flow, layout: ResolvedLayout, answers: Answers
return [{ id: "all", fieldIds: reachablePath(flow, answers) }];
}
- /* SEGMENT_PER_PAGE. Questions with no segment lead, in their own page: they
- * only exist on forms that were never segmented, where they are the whole
- * form, so "one page" is right for them too. */
const pages: FlowPage[] = [];
const unassigned = flow.order.filter((f) => !f.segmentId || !flow.segmentById.has(f.segmentId));
@@ -676,8 +461,6 @@ export function buildPages(flow: Flow, layout: ResolvedLayout, answers: Answers
for (const segment of flow.segments) {
const fieldIds = flow.order.filter((f) => f.segmentId === segment.id).map((f) => f.id);
- // Empty segments are skipped rather than shown as a blank page with a
- // Next button, which is what an author mid-edit would otherwise publish.
if (fieldIds.length === 0) continue;
pages.push({ id: segment.id, segment, fieldIds });
}
@@ -685,13 +468,6 @@ export function buildPages(flow: Flow, layout: ResolvedLayout, answers: Answers
return pages;
}
-/**
- * The questions the current answers actually lead through, from the start.
- *
- * Used by ALL_AT_ONCE, where there is no "next" to resolve but the same
- * routing still has to decide what belongs on the page. Bounded by the
- * question count, which also bounds the walk if rules describe a cycle.
- */
export function reachablePath(flow: Flow, answers: Answers): string[] {
const first = flow.order[0];
if (!first) return [];
@@ -709,28 +485,12 @@ export function reachablePath(flow: Flow, answers: Answers): string[] {
return path;
}
-/** The page holding a question, or -1. */
export function pageIndexOfField(pages: readonly FlowPage[], fieldId: string): number {
return pages.findIndex((page) => page.fieldIds.includes(fieldId));
}
export type NextPage = { kind: "page"; pageIndex: number } | { kind: "end" };
-/**
- * Where does the respondent go after finishing a page?
- *
- * Each question on the page is asked, in order, whether one of its rules
- * decides — the same first-match-wins evaluation used one question at a time.
- * The first decision that leads *off this page* wins.
- *
- * Decisions that land back on the same page are skipped rather than obeyed,
- * and that's the one genuinely page-specific rule here: "jump to question 3"
- * means nothing when question 3 is already on screen and already answerable.
- * Obeying it would either re-show the page or, worse, skip the rest of it.
- *
- * No decision at all means the next page in order, which keeps an unbranched
- * multi-page form behaving exactly as its author laid it out.
- */
export function resolveNextPage(args: {
flow: Flow;
pages: readonly FlowPage[];
@@ -747,8 +507,6 @@ export function resolveNextPage(args: {
const guard = (pageIndex: number): NextPage => {
if (pageIndex < 0 || pageIndex >= pages.length) return { kind: "end" };
- // Same cycle protection as the question-level walk: a page already visited
- // ends the form rather than trapping the respondent in a loop.
if (seen.has(pageIndex)) return { kind: "end" };
return { kind: "page", pageIndex };
};
@@ -769,7 +527,6 @@ export function resolveNextPage(args: {
return guard(currentPageIndex + 1);
}
-/** Remaining pages ahead, following current answers. Progress only. */
export function estimateRemainingPages(args: {
flow: Flow;
pages: readonly FlowPage[];
diff --git a/apps/web/lib/form-logic.ts b/apps/web/lib/form-logic.ts
index 5d8cf57..b5b8962 100644
--- a/apps/web/lib/form-logic.ts
+++ b/apps/web/lib/form-logic.ts
@@ -1,13 +1,3 @@
-/**
- * Turning branch rules into words, and finding the ones that are broken.
- *
- * Both jobs belong together and belong outside the components: the sentence a
- * rule reads as and the problems it has are properties of the rule, not of the
- * dialog that happens to show them. Keeping them here means the inspector
- * summary, the dialog, and any future publish-time check all say the same
- * thing, and it can all be exercised without rendering anything.
- */
-
import {
type Flow,
type FlowRule,
@@ -20,7 +10,6 @@ import {
/* ─── Vocabulary ──────────────────────────────────────────────────────── */
-/** Reads as the middle of a sentence: "Plan **is** Pro". */
export const OPERATOR_LABELS: Record = {
EQUALS: "is",
NOT_EQUALS: "is not",
@@ -43,9 +32,6 @@ export const ACTION_LABELS: Record = {
CONTINUE: "Continue in order",
};
-/** Operators worth offering for a given question type. Showing "is more than"
- * on a checkbox question invites a rule that can never match, so the list is
- * narrowed rather than explained. */
export function operatorsForFieldType(type: string | undefined): LogicOperator[] {
const presence: LogicOperator[] = ["IS_EMPTY", "IS_NOT_EMPTY"];
@@ -66,7 +52,6 @@ export function operatorsForFieldType(type: string | undefined): LogicOperator[]
case "TOGGLE":
return ["EQUALS", ...presence];
default:
- // Free text and everything unrecognised.
return [
"EQUALS",
"NOT_EQUALS",
@@ -81,8 +66,6 @@ export function operatorsForFieldType(type: string | undefined): LogicOperator[]
}
}
-/** The fixed choices a question offers, if it has any. Mirrors how the builder
- * and renderer both read `options`. */
export function choicesForField(field: { options?: unknown } | undefined): string[] {
if (!field) return [];
const options = field.options;
@@ -97,7 +80,6 @@ export function choicesForField(field: { options?: unknown } | undefined): strin
/* ─── Describing ──────────────────────────────────────────────────────── */
export interface FlowLabels {
- /** Display name for a question, already numbered by the caller. */
fieldLabel: (fieldId: string) => string;
segmentLabel: (segmentId: string) => string;
}
@@ -141,15 +123,6 @@ function describeSide(
}
}
-/**
- * A rule as one sentence, e.g.
- * "If all of: Plan is “Pro”, Team size is more than “50” → go to Q7,
- * otherwise finish the form".
- *
- * The summary exists so the inspector can show what a branch does without
- * enough room to show its controls, and so the dialog can label a collapsed
- * rule. It's the same text in both places on purpose.
- */
export function describeRule(rule: FlowRule, labels: FlowLabels): string {
const joiner = rule.match === "ANY" ? "any of" : "all of";
@@ -175,9 +148,7 @@ export type FlowIssueLevel = "error" | "warning";
export interface FlowIssue {
level: FlowIssueLevel;
message: string;
- /** The rule this is about, when it is about one. */
ruleId?: string;
- /** The question this is about, for issues that aren't rule-specific. */
fieldId?: string;
}
@@ -199,7 +170,6 @@ function sideIsComplete(
return true;
}
-/** Whether a rule is finished enough to be saved and to have any effect. */
export function isRuleComplete(rule: FlowRule): boolean {
if (rule.conditions.length === 0) return false;
if (!rule.conditions.every(isConditionComplete)) return false;
@@ -224,16 +194,6 @@ function firstFieldOfSegmentForLint(flow: Flow, segmentId: string): string | nul
return null;
}
-/**
- * Problems worth showing the author before they publish.
- *
- * Reachability is computed over the union of every edge any answer could
- * follow, which deliberately over-approximates: a question this says is
- * reachable might still be hard to reach, but a question it calls unreachable
- * genuinely cannot be shown to anyone. Erring that way matters because the
- * alternative — enumerating answer combinations — is exponential, and a lint
- * that cries wolf gets ignored.
- */
export function lintFlow(flow: Flow, labels: FlowLabels): FlowIssue[] {
const issues: FlowIssue[] = [];
const allRules = [...flow.rulesByFieldId.values()].flat();
@@ -284,9 +244,6 @@ export function lintFlow(flow: Flow, labels: FlowLabels): FlowIssue[] {
});
}
- // A jump backwards is legal but almost always a mistake: the respondent
- // has already answered that question, and the visited-set guard will end
- // the form rather than ask it again.
const from = flow.positionById.get(rule.fieldId);
for (const target of [rule.targetFieldId, rule.elseTargetFieldId]) {
if (!target) continue;
@@ -301,8 +258,6 @@ export function lintFlow(flow: Flow, labels: FlowLabels): FlowIssue[] {
}
}
- // Any rule after one that always decides can never be reached, because the
- // earlier rule returns on both its branches.
for (const [fieldId, rules] of flow.rulesByFieldId) {
const decisive = rules.findIndex((r) => !!r.elseAction && isRuleComplete(r));
if (decisive === -1) continue;
@@ -315,7 +270,6 @@ export function lintFlow(flow: Flow, labels: FlowLabels): FlowIssue[] {
}
}
- /* Reachability over the union of possible edges. */
const edges = new Map>();
const addEdge = (from: string, to: string | null | undefined) => {
if (!to) return;
@@ -328,8 +282,6 @@ export function lintFlow(flow: Flow, labels: FlowLabels): FlowIssue[] {
const rules = flow.rulesByFieldId.get(field.id) ?? [];
const next = flow.order[i + 1]?.id;
- // The linear edge survives unless every rule here decides both ways —
- // in which case nothing ever simply falls through.
const alwaysDecided =
rules.length > 0 && rules.some((r) => !!r.elseAction && isRuleComplete(r));
const anyContinue = rules.some((r) => r.action === "CONTINUE" || r.elseAction === "CONTINUE");
diff --git a/apps/web/lib/fractional-index.ts b/apps/web/lib/fractional-index.ts
index 2159993..e1f0203 100644
--- a/apps/web/lib/fractional-index.ts
+++ b/apps/web/lib/fractional-index.ts
@@ -1,70 +1,23 @@
-/**
- * Fractional indexing helpers for the form builder.
- *
- * Fields are ordered by a `numeric` column with UNIQUE(form_id, index).
- * Fractional indexing is what lets a reorder write a single row: to move a
- * field between two neighbours you give it a value strictly between theirs,
- * and nothing else has to change. That matters here because the builder saves
- * every field with an independent, concurrent UPDATE — a scheme that had to
- * renumber the whole list would pass through states where two rows hold the
- * same index and trip the unique constraint.
- *
- * The invariant the whole scheme rests on: the value handed back is strictly
- * between the two neighbours. The previous implementation rounded midpoints
- * with `.toFixed(2)`, which breaks that invariant once a gap gets narrower
- * than 0.01 — `(1.00 + 1.01) / 2` became `"1.00"`, a duplicate of the
- * neighbour, and the save failed on the unique constraint. Nothing here
- * rounds.
- */
-
-/** Decimal places used only when a value is small enough that
- * `Number#toString` would switch to exponent notation. */
const EXPANDED_DECIMALS = 20;
-/**
- * Serialise an index for the `numeric` column.
- *
- * `Number#toString` gives the shortest string that round-trips to the same
- * double, which keeps stored values tidy ("1.5" rather than
- * "1.50000000000000000000"). It does switch to exponent form below ~1e-6;
- * Postgres `numeric` would accept `5e-7`, but a plain decimal is easier to
- * read and diff, so those get expanded.
- */
export function formatIndex(value: number): string {
const s = String(value);
if (!s.includes("e") && !s.includes("E")) return s;
return value.toFixed(EXPANDED_DECIMALS).replace(/0+$/, "").replace(/\.$/, "");
}
-/** Parse a stored index. Returns NaN for unparseable input so callers can
- * decide, rather than silently coercing to 0 and reordering the form. */
export function parseIndex(value: unknown): number {
return Number.parseFloat(String(value));
}
-/**
- * A value strictly between `before` and `after`, either of which may be null
- * to mean "no neighbour on that side".
- *
- * Returns null when no such value can be produced:
- * - both neighbours are null (nothing to position against), or
- * - the gap is exhausted, i.e. the two neighbours are adjacent doubles so no
- * representable value sits between them. Reaching this needs roughly 50
- * consecutive drops into the same gap; callers should treat it as a signal
- * to renumber rather than as an error.
- */
export function indexBetween(before: number | null, after: number | null): string | null {
const hasBefore = before !== null && Number.isFinite(before);
const hasAfter = after !== null && Number.isFinite(after);
if (!hasBefore && !hasAfter) return null;
- // Appending past the last field.
if (!hasAfter) return formatIndex(before! + 1);
- // Inserting above the first field. Halving keeps the value positive, which
- // matters because indices are conventionally > 0 here; if the head is
- // already at or below zero, step down instead.
if (!hasBefore) {
const head = after!;
return formatIndex(head > 0 ? head / 2 : head - 1);
@@ -72,22 +25,13 @@ export function indexBetween(before: number | null, after: number | null): strin
const lo = before!;
const hi = after!;
- // Out-of-order neighbours mean the caller's list wasn't sorted; refuse
- // rather than invent a value that breaks the ordering.
if (lo >= hi) return null;
- // `lo + (hi - lo) / 2` rather than `(lo + hi) / 2`: no intermediate
- // overflow, and it stays accurate when the two are very close.
const mid = lo + (hi - lo) / 2;
if (mid <= lo || mid >= hi) return null;
return formatIndex(mid);
}
-/**
- * Whether `index` already sits strictly between its neighbours, i.e. the
- * field is correctly placed and needs no write. A null neighbour is treated
- * as unbounded on that side.
- */
export function isBetween(index: number, before: number | null, after: number | null): boolean {
if (!Number.isFinite(index)) return false;
const lo = before !== null && Number.isFinite(before) ? before : Number.NEGATIVE_INFINITY;
diff --git a/apps/web/lib/upload.ts b/apps/web/lib/upload.ts
new file mode 100644
index 0000000..2217831
--- /dev/null
+++ b/apps/web/lib/upload.ts
@@ -0,0 +1,277 @@
+import { env } from "~/env.js";
+
+export interface UploadedFileRef {
+ uploadId: string;
+ claimToken: string;
+ name: string;
+ mimeType: string;
+ sizeBytes: number;
+ status: "pending" | "processing" | "ready" | "failed";
+ url: string | null;
+ error: string | null;
+}
+
+export function apiOrigin(): string {
+ const raw = env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
+ return raw.replace(/\/$/, "").replace(/\/trpc$/, "");
+}
+
+export class UploadError extends Error {
+ readonly status: number;
+ constructor(message: string, status: number) {
+ super(message);
+ this.name = "UploadError";
+ this.status = status;
+ }
+}
+
+interface StartUploadOptions {
+ formId: string;
+ fieldId: string;
+ file: File;
+ onProgress?: (percent: number) => void;
+ signal?: AbortSignal;
+}
+
+export function startUpload({
+ formId,
+ fieldId,
+ file,
+ onProgress,
+ signal,
+}: StartUploadOptions): Promise {
+ return new Promise((resolve, reject) => {
+ const body = new FormData();
+ body.append("file", file);
+
+ const request = new XMLHttpRequest();
+ request.open("POST", `${apiOrigin()}/uploads/${formId}/${fieldId}`);
+ request.withCredentials = true;
+
+ request.upload.onprogress = (event) => {
+ if (!event.lengthComputable) return;
+ onProgress?.(Math.round((event.loaded / event.total) * 100));
+ };
+
+ request.onload = () => {
+ let parsed: Record = {};
+ try {
+ parsed = JSON.parse(request.responseText) as Record;
+ } catch {
+ /* A non-JSON body means something in front of the API answered — a proxy
+ * error page, usually. Handled by the status check below. */
+ }
+
+ if (request.status !== 202) {
+ reject(
+ new UploadError(
+ typeof parsed.error === "string" ? parsed.error : "Upload failed",
+ request.status,
+ ),
+ );
+ return;
+ }
+
+ resolve({
+ uploadId: String(parsed.uploadId),
+ claimToken: String(parsed.claimToken),
+ name: typeof parsed.name === "string" ? parsed.name : file.name,
+ mimeType: typeof parsed.mimeType === "string" ? parsed.mimeType : file.type,
+ sizeBytes: typeof parsed.sizeBytes === "number" ? parsed.sizeBytes : file.size,
+ status: "pending",
+ url: null,
+ error: null,
+ });
+ };
+
+ request.onerror = () => reject(new UploadError("Network error while uploading", 0));
+ request.onabort = () => reject(new UploadError("Upload cancelled", 0));
+
+ signal?.addEventListener("abort", () => request.abort(), { once: true });
+
+ request.send(body);
+ });
+}
+
+/** Read one upload's current state. */
+export async function fetchUploadStatus(
+ uploadId: string,
+ claimToken: string,
+ signal?: AbortSignal,
+): Promise {
+ const response = await fetch(`${apiOrigin()}/uploads/${uploadId}`, {
+ headers: { "X-Upload-Token": claimToken },
+ credentials: "include",
+ ...(signal ? { signal } : {}),
+ });
+
+ if (!response.ok) {
+ throw new UploadError("Could not read upload status", response.status);
+ }
+
+ const data = (await response.json()) as {
+ uploadId: string;
+ status: UploadedFileRef["status"];
+ name: string;
+ mimeType: string;
+ sizeBytes: number;
+ url: string | null;
+ error: string | null;
+ };
+
+ return { ...data, claimToken };
+}
+
+export const UPLOAD_STILL_PROCESSING = "UPLOAD_STILL_PROCESSING";
+
+export async function waitForUpload(
+ ref: UploadedFileRef,
+ options: { signal?: AbortSignal; onUpdate?: (next: UploadedFileRef) => void } = {},
+): Promise<{ ref: UploadedFileRef; timedOut: boolean }> {
+ const deadline = Date.now() + 120_000;
+ let delay = 1_000;
+ let latest = ref;
+
+ while (Date.now() < deadline) {
+ if (options.signal?.aborted) return { ref: latest, timedOut: false };
+
+ await new Promise((done) => setTimeout(done, delay));
+ delay = Math.min(delay * 1.5, 5_000);
+
+ try {
+ latest = await fetchUploadStatus(ref.uploadId, ref.claimToken, options.signal);
+ options.onUpdate?.(latest);
+ if (latest.status === "ready" || latest.status === "failed") {
+ return { ref: latest, timedOut: false };
+ }
+ } catch {
+ /* A failed poll is not a failed upload — the file may well be stored
+ * already. Keep trying until the deadline. */
+ }
+ }
+
+ return { ref: latest, timedOut: true };
+}
+
+/** Human-readable size for the file chip. */
+export function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export function isUploadAnswerComplete(value: unknown): boolean {
+ const refs = Array.isArray(value) ? value : [value];
+ if (refs.length === 0) return false;
+
+ return refs.every((entry) => {
+ const ref = entry as UploadedFileRef | null;
+ if (!ref || typeof ref.uploadId !== "string") return false;
+ // `pending` and `processing` are both "not finished". `failed` is terminal
+ // and unusable.
+ return ref.status === "ready";
+ });
+}
+
+export function stripUploadSecrets(value: unknown): unknown {
+ const scrub = (entry: unknown): unknown => {
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return entry;
+ if (!("claimToken" in entry)) return entry;
+
+ const rest: Record = {};
+ for (const [key, item] of Object.entries(entry as Record)) {
+ if (key !== "claimToken") rest[key] = item;
+ }
+ return rest;
+ };
+
+ return Array.isArray(value) ? value.map(scrub) : scrub(value);
+}
+
+export function downloadUrlFor(url: string, originalName: string): string {
+ const marker = "/upload/";
+ const at = url.indexOf(marker);
+ if (at === -1) return url;
+
+ const base = originalName.replace(/\.[^.]+$/, "");
+ const slug = base
+ .normalize("NFKD")
+ .replace(/[^a-zA-Z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80);
+
+ if (!slug) return url;
+
+ const head = url.slice(0, at + marker.length);
+ const tail = url.slice(at + marker.length);
+ return `${head}fl_attachment:${encodeURIComponent(slug)}/${tail}`;
+}
+
+/** Size ceilings the server enforces, in MB, per kind of file. */
+export interface UploadLimits {
+ maxMb: number;
+ image: number;
+ video: number;
+ raw: number;
+}
+
+let limitsPromise: Promise | null = null;
+
+export function fetchUploadLimits(): Promise {
+ if (limitsPromise) return limitsPromise;
+
+ limitsPromise = fetch(`${apiOrigin()}/uploads/limits`)
+ .then((res) => (res.ok ? (res.json() as Promise) : null))
+ .catch(() => null);
+
+ return limitsPromise;
+}
+
+function kindsFor(accept: string[] | undefined): Array> {
+ if (!accept || accept.length === 0) return ["image", "video", "raw"];
+
+ const kinds = new Set>();
+
+ for (const raw of accept) {
+ const rule = raw.trim().toLowerCase();
+ if (rule.startsWith("image/")) kinds.add("image");
+ else if (rule.startsWith("video/") || rule.startsWith("audio/")) kinds.add("video");
+ else if (rule.startsWith(".")) {
+ kinds.add("image");
+ kinds.add("video");
+ kinds.add("raw");
+ } else kinds.add("raw");
+ }
+
+ return [...kinds];
+}
+
+export function limitLabel(
+ accept: string[] | undefined,
+ authorMaxMb: number | undefined,
+ limits: UploadLimits | null,
+): string | null {
+ if (!limits) return authorMaxMb ? `up to ${authorMaxMb}MB` : null;
+
+ const ceiling = authorMaxMb ?? Number.POSITIVE_INFINITY;
+ const kinds = kindsFor(accept);
+
+ const effective = kinds.map((kind) => ({
+ kind,
+ mb: Math.min(ceiling, limits[kind], limits.maxMb),
+ }));
+
+ const distinct = [...new Set(effective.map((entry) => entry.mb))];
+
+ if (distinct.length === 1) return `up to ${distinct[0]}MB`;
+
+ const wording: Record