diff --git a/app/globals.css b/app/globals.css
index 9ca2d70..6def69c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -12,11 +12,20 @@
--accent: #4f7a58;
--accent-soft: #dbe6dd;
--accent-ink: #2f5d35;
+ --danger: #e11d48;
+ --danger-hover: #be123c;
+ --danger-contrast: #fff8fb;
--danger-soft: rgba(190, 24, 93, 0.08);
--danger-ink: #be123c;
--danger-line: rgba(190, 24, 93, 0.24);
--line: rgba(23, 23, 23, 0.08);
--line-strong: rgba(23, 23, 23, 0.14);
+ --shadow-surface: 0 22px 60px rgba(15, 23, 42, 0.08);
+ --shadow-elevated: 0 18px 44px rgba(15, 23, 42, 0.08);
+ --shadow-button: 0 16px 40px rgba(15, 23, 42, 0.18);
+ --shadow-button-hover: 0 20px 48px rgba(15, 23, 42, 0.24);
+ --shadow-toast: 0 18px 50px rgba(15, 23, 42, 0.12);
+ --shadow-accent: 0 16px 40px rgba(79, 122, 88, 0.1);
}
:root[data-theme="dark"] {
@@ -31,11 +40,20 @@
--accent: #8db896;
--accent-soft: rgba(141, 184, 150, 0.16);
--accent-ink: #d7ecd9;
+ --danger: #ff6b93;
+ --danger-hover: #ff5b88;
+ --danger-contrast: #220710;
--danger-soft: rgba(190, 24, 93, 0.16);
--danger-ink: #ffb3c7;
--danger-line: rgba(255, 179, 199, 0.28);
--line: rgba(255, 255, 255, 0.1);
--line-strong: rgba(255, 255, 255, 0.18);
+ --shadow-surface: 0 22px 60px rgba(0, 0, 0, 0.24);
+ --shadow-elevated: 0 18px 44px rgba(0, 0, 0, 0.22);
+ --shadow-button: 0 16px 40px rgba(0, 0, 0, 0.32);
+ --shadow-button-hover: 0 20px 48px rgba(0, 0, 0, 0.4);
+ --shadow-toast: 0 18px 50px rgba(0, 0, 0, 0.28);
+ --shadow-accent: 0 16px 40px rgba(79, 122, 88, 0.22);
color-scheme: dark;
}
diff --git a/components/dashboard-form-browser.tsx b/components/dashboard-form-browser.tsx
index e4352cb..526a5e5 100644
--- a/components/dashboard-form-browser.tsx
+++ b/components/dashboard-form-browser.tsx
@@ -6,10 +6,10 @@ import { ArrowDown, ArrowUp, LayoutGrid, Plus, TableProperties } from "lucide-re
import { useEffect, useMemo, useState } from "react";
import { createFormAction } from "@/app/(creator)/actions";
-import { Badge } from "@/components/ui/badge";
+import { FormStatusBadge } from "@/components/form-status-badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
-import type { FormListItem } from "@/lib/forms";
+import type { FormListItem } from "@/lib/form-types";
import { cn, formatDate } from "@/lib/utils";
type DashboardView = "grid" | "table";
@@ -155,9 +155,7 @@ export function DashboardFormBrowser({ forms }: { forms: FormListItem[] }) {
{form.title}
-
- {form.status === "PUBLISHED" ? "Published" : "Draft"}
-
+
{form.description || "No description yet."}
@@ -228,9 +226,7 @@ export function DashboardFormBrowser({ forms }: { forms: FormListItem[] }) {
-
- {form.status === "PUBLISHED" ? "Published" : "Draft"}
-
+
|
{form.responseCount} |
{formatDate(form.updatedAt)} |
diff --git a/components/form-builder-panels.tsx b/components/form-builder-panels.tsx
new file mode 100644
index 0000000..c1a5692
--- /dev/null
+++ b/components/form-builder-panels.tsx
@@ -0,0 +1,304 @@
+import { Copy, Link as LinkIcon, LoaderCircle, MessagesSquare, Plus, Save, Settings2, Trash2, type LucideIcon } from "lucide-react";
+import Link from "next/link";
+import type { Dispatch, SetStateAction } from "react";
+
+import { FormStatusBadge } from "@/components/form-status-badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ blockTypeLabels,
+ isQuestionBlock,
+ type BlockConfig,
+ type ChoiceBlockConfig,
+ type LongTextBlockConfig,
+ type ShortTextBlockConfig,
+ type TextBlockConfig,
+} from "@/lib/blocks";
+import type { BuilderBlock, BuilderForm } from "@/lib/form-types";
+
+export type FormMetadataDraft = {
+ title: string;
+ description: string;
+ completionTitle: string;
+ completionMessage: string;
+ completionLinkLabel: string;
+ completionLinkUrl: string;
+ slug: string;
+};
+
+export function BuilderHeader({
+ form,
+ settingsSelected,
+ busy,
+ onOpenSettings,
+ onTogglePublished,
+}: {
+ form: BuilderForm;
+ settingsSelected: boolean;
+ busy: string | null;
+ onOpenSettings: () => void;
+ onTogglePublished: () => void;
+}) {
+ return (
+
+
+
Builder
+
+
{form.title}
+
+
+
Edit blocks, update settings, and review responses.
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function FormSettingsPanel({
+ metadataDraft,
+ setMetadataDraft,
+ shareHref,
+ copyShareLink,
+ deleteForm,
+ saveMetadata,
+ busy,
+}: {
+ metadataDraft: FormMetadataDraft;
+ setMetadataDraft: Dispatch>;
+ shareHref: string;
+ copyShareLink: () => void;
+ deleteForm: () => void;
+ saveMetadata: () => void;
+ busy: string | null;
+}) {
+ return (
+
+
+
Settings
+
+
+
+
+
+
+
+
+
+
+
After submission
+
+ Customize what respondents see after they finish the form, including an optional next-step link.
+
+
+
+
+
+
+
+
+
Public route
+
{shareHref}
+
+ Published forms accept anonymous submissions. Changes to a published form go live immediately.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function BlockEditorPanel({
+ blockDraft,
+ selectedBlockIcon: SelectedBlockIcon,
+ choiceOptionsDraft,
+ setChoiceOptionsDraft,
+ setBlockDraft,
+ deleteBlock,
+ saveBlock,
+ busy,
+}: {
+ blockDraft: BuilderBlock;
+ selectedBlockIcon: LucideIcon | null;
+ choiceOptionsDraft: string;
+ setChoiceOptionsDraft: (value: string) => void;
+ setBlockDraft: Dispatch>;
+ deleteBlock: (blockId: string) => void;
+ saveBlock: () => void;
+ busy: string | null;
+}) {
+ return (
+
+
+
+ {SelectedBlockIcon ? : null}
+
{blockTypeLabels[blockDraft.type]}
+
+
+ {blockDraft.position + 1}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function EmptyEditorState({ onAddShortText }: { onAddShortText: () => void }) {
+ return (
+
+
+
Nothing selected
+
Select a block or open form settings.
+
Use the left panel to choose what you want to edit.
+
+
+
+
+
+ );
+}
diff --git a/components/form-builder.tsx b/components/form-builder.tsx
index 41f9ce6..44172a1 100644
--- a/components/form-builder.tsx
+++ b/components/form-builder.tsx
@@ -17,62 +17,33 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
- Copy,
GripVertical,
- Link as LinkIcon,
LoaderCircle,
- Plus,
- MessagesSquare,
Radio,
Rows3,
- Save,
- Settings2,
SquareCheck,
TextCursorInput,
- Trash2,
Type,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
+import {
+ BlockEditorPanel,
+ BuilderHeader,
+ EmptyEditorState,
+ FormSettingsPanel,
+ type FormMetadataDraft,
+} from "@/components/form-builder-panels";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
import { ToastViewport, type ToastData } from "@/components/ui/toast";
-import { Textarea } from "@/components/ui/textarea";
+import { blockTypeLabels, getBlockPreview, type ChoiceBlockConfig } from "@/lib/blocks";
+import type { BuilderBlock, BuilderForm } from "@/lib/form-types";
import { cn } from "@/lib/utils";
-type BlockType = "TEXT" | "SHORT_TEXT" | "LONG_TEXT" | "SINGLE_CHOICE" | "MULTIPLE_CHOICE";
-
-type TextConfig = { body: string };
-type PlaceholderConfig = { placeholder: string };
-type ChoiceConfig = { options: string[] };
-
-type BuilderBlock = {
- id: string;
- type: BlockType;
- title: string;
- description: string;
- required: boolean;
- position: number;
- config: TextConfig | PlaceholderConfig | ChoiceConfig;
-};
-
-type BuilderForm = {
- id: string;
- title: string;
- description: string;
- completionTitle: string;
- completionMessage: string;
- completionLinkLabel: string | null;
- completionLinkUrl: string | null;
- slug: string;
- status: "DRAFT" | "PUBLISHED";
- updatedAt: string;
- responseCount: number;
- blocks: BuilderBlock[];
-};
+type BlockType = BuilderBlock["type"];
type Selection = { kind: "form" } | { kind: "block"; blockId: string };
@@ -84,14 +55,6 @@ const blockIcons: Record = {
MULTIPLE_CHOICE: SquareCheck,
};
-const blockLabels: Record = {
- TEXT: "Text block",
- SHORT_TEXT: "Short text",
- LONG_TEXT: "Long text",
- SINGLE_CHOICE: "Single choice",
- MULTIPLE_CHOICE: "Multiple choice",
-};
-
const blockCreationOrder: BlockType[] = ["TEXT", "SHORT_TEXT", "LONG_TEXT", "SINGLE_CHOICE", "MULTIPLE_CHOICE"];
async function fetchJson(url: string, init?: RequestInit) {
@@ -112,30 +75,6 @@ async function fetchJson(url: string, init?: RequestInit) {
return payload;
}
-function getBlockPreview(block: BuilderBlock) {
- if (block.title.trim()) {
- return block.title;
- }
-
- if (block.type === "TEXT") {
- return (block.config as TextConfig).body;
- }
-
- if ("placeholder" in block.config) {
- return block.config.placeholder;
- }
-
- if ("options" in block.config) {
- return block.config.options.join(" • ");
- }
-
- return blockLabels[block.type];
-}
-
-function isQuestion(type: BlockType) {
- return type !== "TEXT";
-}
-
function BlockRowInner({
block,
onSelect,
@@ -154,7 +93,7 @@ function BlockRowInner({
- {blockLabels[block.type]}
+ {blockTypeLabels[block.type]}
{block.position + 1}
@@ -187,7 +126,7 @@ function SortableBlockRow({
className={cn(
"group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition",
selected
- ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[0_18px_44px_rgba(15,23,42,0.08)]"
+ ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]"
: "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]",
)}
>
@@ -223,7 +162,7 @@ function StaticBlockRow({
className={cn(
"group flex items-center gap-2.5 rounded-[16px] border px-3 py-3 transition",
selected
- ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[0_18px_44px_rgba(15,23,42,0.08)]"
+ ? "border-[var(--accent)] bg-[var(--surface-strong)] shadow-[var(--shadow-elevated)]"
: "border-[color:var(--line)] bg-[var(--surface)] hover:bg-[var(--surface-strong)]",
)}
>
@@ -250,7 +189,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
const [busy, setBusy] = useState(null);
const [isDndReady, setIsDndReady] = useState(false);
- const [metadataDraft, setMetadataDraft] = useState({
+ const [metadataDraft, setMetadataDraft] = useState({
title: initialForm.title,
description: initialForm.description,
completionTitle: initialForm.completionTitle,
@@ -268,7 +207,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
const [blockDraft, setBlockDraft] = useState(selectedBlock);
const [choiceOptionsDraft, setChoiceOptionsDraft] = useState(
selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE")
- ? (selectedBlock.config as ChoiceConfig).options.join("\n")
+ ? (selectedBlock.config as ChoiceBlockConfig).options.join("\n")
: "",
);
const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null;
@@ -291,7 +230,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
setBlockDraft(selectedBlock);
setChoiceOptionsDraft(
selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE")
- ? (selectedBlock.config as ChoiceConfig).options.join("\n")
+ ? (selectedBlock.config as ChoiceBlockConfig).options.join("\n")
: "",
);
}, [selectedBlock]);
@@ -346,7 +285,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
blocks: [...current.blocks, payload.block],
}));
setSelection({ kind: "block", blockId: payload.block.id });
- showToast(`Added ${blockLabels[type].toLowerCase()}`);
+ showToast(`Added ${blockTypeLabels[type].toLowerCase()}`);
});
}
@@ -475,35 +414,13 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
<>
-
-
-
Builder
-
-
{form.title}
- {form.status}
-
-
Edit blocks, update settings, and review responses.
-
-
-
-
-
-
-
-
-
+
setSelection({ kind: "form" })}
+ onTogglePublished={togglePublished}
+ />
@@ -553,7 +470,7 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
onClick={() => addBlock(type)}
>
{busy === `add-${type}` ? : }
- {blockLabels[type]}
+ {blockTypeLabels[type]}
);
})}
@@ -563,229 +480,28 @@ export function FormBuilder({ initialForm }: { initialForm: BuilderForm }) {
{selection.kind === "form" ? (
-
-
-
Settings
-
-
-
-
-
-
-
-
-
-
-
After submission
-
- Customize what respondents see after they finish the form, including an optional next-step link.
-
-
-
-
-
-
-
-
-
Public route
-
{shareHref}
-
- Published forms accept anonymous submissions. Changes to a published form go live immediately.
-
-
-
-
-
-
-
-
-
-
-
-
-
+
) : blockDraft ? (
-
-
-
- {SelectedBlockIcon ? : null}
-
{blockLabels[blockDraft.type]}
-
-
- {blockDraft.position + 1}
-
-
-
-
-
-
-
-
-
-
+
) : (
-
-
-
Nothing selected
-
Select a block or open form settings.
-
- Use the left panel to choose what you want to edit.
-
-
-
-
-
-
+ addBlock("SHORT_TEXT")} />
)}
diff --git a/components/form-status-badge.tsx b/components/form-status-badge.tsx
new file mode 100644
index 0000000..e5d4f50
--- /dev/null
+++ b/components/form-status-badge.tsx
@@ -0,0 +1,18 @@
+import type { FormStatus } from "@prisma/client";
+
+import { Badge } from "@/components/ui/badge";
+import { cn, sentenceCase } from "@/lib/utils";
+
+export function FormStatusBadge({ status, className }: { status: FormStatus; className?: string }) {
+ return (
+
+ {sentenceCase(status)}
+
+ );
+}
diff --git a/components/loading-shell.tsx b/components/loading-shell.tsx
index 291da1a..2d898c0 100644
--- a/components/loading-shell.tsx
+++ b/components/loading-shell.tsx
@@ -4,8 +4,8 @@ export function LoadingShell({ title }: { title: string }) {
Loading {title}…
diff --git a/components/public-form-runner.tsx b/components/public-form-runner.tsx
index c997090..717776e 100644
--- a/components/public-form-runner.tsx
+++ b/components/public-form-runner.tsx
@@ -11,35 +11,10 @@ import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ToastViewport, type ToastData } from "@/components/ui/toast";
import { Textarea } from "@/components/ui/textarea";
+import type { ChoiceBlockConfig, LongTextBlockConfig, ShortTextBlockConfig, TextBlockConfig } from "@/lib/blocks";
+import type { PublicForm } from "@/lib/form-types";
import { cn } from "@/lib/utils";
-type BlockType = "TEXT" | "SHORT_TEXT" | "LONG_TEXT" | "SINGLE_CHOICE" | "MULTIPLE_CHOICE";
-
-type PublicBlock = {
- id: string;
- type: BlockType;
- title: string;
- description: string;
- required: boolean;
- position: number;
- config:
- | { body: string }
- | { placeholder: string }
- | { options: string[] };
-};
-
-type PublicForm = {
- id: string;
- title: string;
- description: string;
- completionTitle: string;
- completionMessage: string;
- completionLinkLabel: string | null;
- completionLinkUrl: string | null;
- slug: string;
- blocks: PublicBlock[];
-};
-
async function submitResponse(slug: string, answers: Record) {
const response = await fetch(`/api/public/forms/${slug}/responses`, {
method: "POST",
@@ -241,7 +216,7 @@ export function PublicFormRunner({ form }: { form: PublicForm }) {
{currentBlock.title || "Take a breath before the next step."}
{currentBlock.description ? {currentBlock.description}
: null}
- {(currentBlock.config as { body: string }).body}
+ {(currentBlock.config as TextBlockConfig).body}
) : (