this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: expand form blocks and refine runner

+1607 -170
+15 -2
app/(creator)/forms/[id]/responses/[responseId]/page.tsx
··· 4 4 import { Badge } from "@/components/ui/badge"; 5 5 import { Button } from "@/components/ui/button"; 6 6 import { Card } from "@/components/ui/card"; 7 + import { AGREEMENT_ANSWER_VALUES } from "@/lib/blocks"; 7 8 import { getServerAuthSession } from "@/lib/auth"; 8 9 import { AppError } from "@/lib/errors"; 9 10 import { getOwnedResponseDetail } from "@/lib/forms"; 10 11 import { getRequestI18n } from "@/lib/i18n-server"; 11 - import { formatDate } from "@/lib/utils"; 12 + import { formatCalendarDate, formatDate } from "@/lib/utils"; 12 13 13 14 export default async function ResponseDetailPage({ 14 15 params, ··· 80 81 ))} 81 82 </div> 82 83 ) : answer ? ( 83 - <p className="whitespace-pre-wrap text-base leading-8">{answer}</p> 84 + block.type === "LINK" ? ( 85 + <Link href={answer} target="_blank" rel="noreferrer" className="break-all text-base leading-8 text-[var(--accent)] hover:underline"> 86 + {answer} 87 + </Link> 88 + ) : block.type === "DATE" ? ( 89 + <p className="whitespace-pre-wrap text-base leading-8">{formatCalendarDate(answer, locale)}</p> 90 + ) : block.type === "AGREEMENT" ? ( 91 + <p className="whitespace-pre-wrap text-base leading-8"> 92 + {answer === AGREEMENT_ANSWER_VALUES.AGREED ? t("publicRunner.agree") : t("publicRunner.doNotAgree")} 93 + </p> 94 + ) : ( 95 + <p className="whitespace-pre-wrap text-base leading-8">{answer}</p> 96 + ) 84 97 ) : ( 85 98 <p className="text-sm text-[var(--muted)]">{t("responseDetail.noAnswer")}</p> 86 99 )}
+17
bun.lock
··· 10 10 "@dnd-kit/sortable": "^10.0.0", 11 11 "@dnd-kit/utilities": "^3.2.2", 12 12 "@prisma/client": "6", 13 + "@radix-ui/react-popover": "^1.1.15", 13 14 "@radix-ui/react-select": "^2.2.6", 14 15 "class-variance-authority": "^0.7.1", 15 16 "clsx": "^2.1.1", 17 + "date-fns": "^4.1.0", 16 18 "framer-motion": "^12.38.0", 17 19 "lucide-react": "^1.7.0", 18 20 "next": "^16.2.2", 19 21 "next-auth": "^4.24.13", 20 22 "react": "^19.2.4", 23 + "react-day-picker": "^9.14.0", 21 24 "react-dom": "^19.2.4", 22 25 "tailwind-merge": "^3.5.0", 23 26 "xlsx": "^0.18.5", ··· 77 80 "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], 78 81 79 82 "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], 83 + 84 + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], 80 85 81 86 "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], 82 87 ··· 254 259 255 260 "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], 256 261 262 + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], 263 + 257 264 "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], 258 265 259 266 "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], 267 + 268 + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], 260 269 261 270 "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], 262 271 ··· 290 299 291 300 "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], 292 301 302 + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], 303 + 293 304 "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], 294 305 295 306 "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], ··· 499 510 "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], 500 511 501 512 "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], 513 + 514 + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], 515 + 516 + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], 502 517 503 518 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 504 519 ··· 911 926 "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], 912 927 913 928 "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], 929 + 930 + "react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], 914 931 915 932 "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], 916 933
-2
components/dashboard-form-browser.tsx
··· 158 158 <h2 className="font-display text-3xl text-[var(--ink)]">{form.title}</h2> 159 159 <FormStatusBadge status={form.status} /> 160 160 </div> 161 - <p className="mt-3 max-w-xl text-sm leading-6 text-[var(--muted)]">{form.description || t("dashboard.noDescription")}</p> 162 161 </div> 163 162 <Link href={`/forms/${form.id}/edit`}> 164 163 <Button size="sm">{t("dashboard.open")}</Button> ··· 223 222 <Link href={`/forms/${form.id}/edit`} className="font-medium text-[var(--ink)] transition hover:text-[var(--accent)]"> 224 223 {form.title} 225 224 </Link> 226 - <p className="mt-1 truncate text-sm text-[var(--muted)]">{form.description || t("dashboard.noDescription")}</p> 227 225 </div> 228 226 </td> 229 227 <td className="px-5 py-4">
+141 -37
components/form-builder-panels.tsx
··· 22 22 import { 23 23 blockTypeTranslationKeys, 24 24 isQuestionBlock, 25 + type AgreementBlockConfig, 25 26 type BlockConfig, 27 + type LinkBlockConfig, 26 28 type LongTextBlockConfig, 29 + type NumberBlockConfig, 27 30 type ShortTextBlockConfig, 28 31 type TextBlockConfig, 29 32 } from "@/lib/blocks"; ··· 31 34 32 35 export type FormMetadataDraft = { 33 36 title: string; 34 - description: string; 35 37 completionTitle: string; 36 38 completionMessage: string; 37 39 completionLinkLabel: string; ··· 120 122 <span className="font-medium text-[var(--ink)]">{t("builder.titleField")}</span> 121 123 <Input value={metadataDraft.title} onChange={(event) => setMetadataDraft((current) => ({ ...current, title: event.target.value }))} /> 122 124 </label> 123 - <label className="grid gap-2 text-sm text-[var(--muted)]"> 124 - <span className="font-medium text-[var(--ink)]">{t("builder.descriptionField")}</span> 125 - <Textarea value={metadataDraft.description} onChange={(event) => setMetadataDraft((current) => ({ ...current, description: event.target.value }))} /> 126 - </label> 127 - <label className="grid gap-2 text-sm text-[var(--muted)]"> 128 - <span className="font-medium text-[var(--ink)]">{t("builder.shareSlug")}</span> 129 - <Input value={metadataDraft.slug} className="font-mono text-[13px]" onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} /> 130 - </label> 131 125 </div> 132 126 133 127 <div className="space-y-5 border-t border-[color:var(--line)] pt-6"> ··· 160 154 </div> 161 155 </div> 162 156 163 - <div className="grid gap-4 border-t border-[color:var(--line)] pt-6 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 157 + <div className="grid gap-5 border-t border-[color:var(--line)] pt-6"> 164 158 <div> 165 159 <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("builder.publicRoute")}</p> 166 - <p className="mt-2 truncate font-mono text-[13px] text-[var(--ink)]">{shareHref}</p> 167 160 <p className="mt-1 text-xs text-[var(--muted)]"> 168 161 {t("builder.publicRouteHelp")} 169 162 </p> 170 163 </div> 171 - <Button variant="secondary" onClick={copyShareLink}> 172 - <Copy className="size-4" /> 173 - {t("builder.copyLink")} 174 - </Button> 175 - <Link href={shareHref} target="_blank"> 176 - <Button variant="secondary"> 177 - <LinkIcon className="size-4" /> 178 - {t("builder.openRunner")} 179 - </Button> 180 - </Link> 164 + <div className="grid gap-2 text-sm text-[var(--muted)]"> 165 + <span className="font-medium text-[var(--ink)]">{t("builder.shareSlug")}</span> 166 + <div className="flex flex-col gap-3 lg:flex-row lg:items-center"> 167 + <div className="flex h-11 min-w-0 flex-1 items-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] focus-within:border-[color:var(--line-strong)] focus-within:ring-2 focus-within:ring-[var(--accent-soft)]"> 168 + <span className="shrink-0 border-r border-[color:var(--line)] px-4 font-mono text-[13px] text-[var(--muted)]">/f/</span> 169 + <input 170 + value={metadataDraft.slug} 171 + className="h-full w-full min-w-0 bg-transparent px-4 font-mono text-[13px] text-[var(--ink)] outline-none" 172 + onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} 173 + /> 174 + </div> 175 + <div className="flex flex-wrap gap-3 lg:shrink-0"> 176 + <Button variant="secondary" onClick={copyShareLink}> 177 + <Copy className="size-4" /> 178 + {t("builder.copyLink")} 179 + </Button> 180 + <Link href={shareHref} target="_blank"> 181 + <Button variant="secondary"> 182 + <LinkIcon className="size-4" /> 183 + {t("builder.openRunner")} 184 + </Button> 185 + </Link> 186 + </div> 187 + </div> 188 + </div> 181 189 </div> 182 190 183 191 <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> ··· 264 272 const { t } = useI18n(); 265 273 const optionSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 266 274 267 - function syncChoiceOptions(nextOptions: ChoiceOptionDraft[]) { 268 - setChoiceOptionsDraft(nextOptions); 275 + function updateConfig(patch: Record<string, unknown>) { 269 276 setBlockDraft((current) => 270 277 current 271 278 ? { 272 279 ...current, 273 - config: { options: nextOptions.map((option) => option.value) } as BlockConfig, 280 + config: { 281 + ...(current.config as Record<string, unknown>), 282 + ...patch, 283 + } as BlockConfig, 274 284 } 275 285 : current, 276 286 ); 287 + } 288 + 289 + function syncChoiceOptions(nextOptions: ChoiceOptionDraft[]) { 290 + setChoiceOptionsDraft(nextOptions); 291 + updateConfig({ options: nextOptions.map((option) => option.value) }); 277 292 } 278 293 279 294 function updateChoiceOption(optionId: string, value: string) { ··· 324 339 325 340 <div className="grid gap-5"> 326 341 <label className="grid gap-2 text-sm text-[var(--muted)]"> 327 - <span className="font-medium text-[var(--ink)]">{t("builder.prompt")}</span> 342 + <span className="font-medium text-[var(--ink)]">{t(blockDraft.type === "TEXT" ? "builder.headingField" : "builder.prompt")}</span> 328 343 <Input value={blockDraft.title} onChange={(event) => setBlockDraft((current) => (current ? { ...current, title: event.target.value } : current))} /> 329 344 </label> 330 - <label className="grid gap-2 text-sm text-[var(--muted)]"> 331 - <span className="font-medium text-[var(--ink)]">{t("builder.supportText")}</span> 332 - <Textarea value={blockDraft.description} onChange={(event) => setBlockDraft((current) => (current ? { ...current, description: event.target.value } : current))} /> 333 - </label> 345 + 346 + {blockDraft.type !== "TEXT" ? ( 347 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 348 + <span className="font-medium text-[var(--ink)]">{t("builder.supportText")}</span> 349 + <Textarea value={blockDraft.description} onChange={(event) => setBlockDraft((current) => (current ? { ...current, description: event.target.value } : current))} /> 350 + </label> 351 + ) : null} 334 352 335 353 {blockDraft.type === "TEXT" ? ( 336 354 <label className="grid gap-2 text-sm text-[var(--muted)]"> 337 355 <span className="font-medium text-[var(--ink)]">{t("builder.bodyCopy")}</span> 338 356 <Textarea 339 357 value={(blockDraft.config as TextBlockConfig).body} 340 - onChange={(event) => setBlockDraft((current) => (current ? { ...current, config: { body: event.target.value } as BlockConfig } : current))} 358 + onChange={(event) => updateConfig({ body: event.target.value })} 341 359 /> 342 360 </label> 343 361 ) : null} 344 362 345 363 {(blockDraft.type === "SHORT_TEXT" || blockDraft.type === "LONG_TEXT") && ( 364 + <> 365 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 366 + <span className="font-medium text-[var(--ink)]">{t("builder.placeholder")}</span> 367 + <Input 368 + value={blockDraft.type === "SHORT_TEXT" ? (blockDraft.config as ShortTextBlockConfig).placeholder : (blockDraft.config as LongTextBlockConfig).placeholder} 369 + onChange={(event) => updateConfig({ placeholder: event.target.value })} 370 + /> 371 + </label> 372 + 373 + <div className="grid gap-3 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-4 text-sm text-[var(--muted)]"> 374 + <label className="flex items-center gap-3 text-[var(--ink)]"> 375 + <input 376 + checked={(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig).validationRegex !== null} 377 + className="size-4 rounded border-[color:var(--line-strong)]" 378 + type="checkbox" 379 + onChange={(event) => updateConfig({ validationRegex: event.target.checked ? "" : null })} 380 + /> 381 + {t("builder.regexValidation")} 382 + </label> 383 + 384 + {(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig).validationRegex !== null ? ( 385 + <label className="grid gap-2"> 386 + <span className="font-medium text-[var(--ink)]">{t("builder.regexPattern")}</span> 387 + <Input 388 + value={(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig).validationRegex ?? ""} 389 + placeholder="^[A-Z0-9]+$" 390 + onChange={(event) => updateConfig({ validationRegex: event.target.value })} 391 + /> 392 + </label> 393 + ) : null} 394 + 395 + <p className="text-xs text-[var(--muted)]">{t("builder.regexHelp")}</p> 396 + </div> 397 + </> 398 + )} 399 + 400 + {blockDraft.type === "NUMBER" ? ( 401 + <div className="grid gap-5 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-4 text-sm text-[var(--muted)]"> 402 + <label className="grid gap-2"> 403 + <span className="font-medium text-[var(--ink)]">{t("builder.placeholder")}</span> 404 + <Input 405 + value={(blockDraft.config as NumberBlockConfig).placeholder} 406 + onChange={(event) => updateConfig({ placeholder: event.target.value })} 407 + /> 408 + </label> 409 + 410 + <label className="flex items-center gap-3 text-[var(--ink)]"> 411 + <input 412 + checked={(blockDraft.config as NumberBlockConfig).allowFloat} 413 + className="size-4 rounded border-[color:var(--line-strong)]" 414 + type="checkbox" 415 + onChange={(event) => updateConfig({ allowFloat: event.target.checked })} 416 + /> 417 + {t("builder.allowFloats")} 418 + </label> 419 + 420 + <div className="grid gap-5 sm:grid-cols-2"> 421 + <label className="grid gap-2"> 422 + <span className="font-medium text-[var(--ink)]">{t("builder.minimumValue")}</span> 423 + <Input 424 + type="number" 425 + step={(blockDraft.config as NumberBlockConfig).allowFloat ? "any" : "1"} 426 + value={(blockDraft.config as NumberBlockConfig).min ?? ""} 427 + onChange={(event) => updateConfig({ min: event.target.value === "" ? null : Number(event.target.value) })} 428 + /> 429 + </label> 430 + <label className="grid gap-2"> 431 + <span className="font-medium text-[var(--ink)]">{t("builder.maximumValue")}</span> 432 + <Input 433 + type="number" 434 + step={(blockDraft.config as NumberBlockConfig).allowFloat ? "any" : "1"} 435 + value={(blockDraft.config as NumberBlockConfig).max ?? ""} 436 + onChange={(event) => updateConfig({ max: event.target.value === "" ? null : Number(event.target.value) })} 437 + /> 438 + </label> 439 + </div> 440 + </div> 441 + ) : null} 442 + 443 + {blockDraft.type === "LINK" ? ( 346 444 <label className="grid gap-2 text-sm text-[var(--muted)]"> 347 445 <span className="font-medium text-[var(--ink)]">{t("builder.placeholder")}</span> 348 446 <Input 349 - value={blockDraft.type === "SHORT_TEXT" ? (blockDraft.config as ShortTextBlockConfig).placeholder : (blockDraft.config as LongTextBlockConfig).placeholder} 350 - onChange={(event) => 351 - setBlockDraft((current) => 352 - current ? { ...current, config: { placeholder: event.target.value } as BlockConfig } : current, 353 - ) 354 - } 447 + value={(blockDraft.config as LinkBlockConfig).placeholder} 448 + onChange={(event) => updateConfig({ placeholder: event.target.value })} 355 449 /> 356 450 </label> 357 - )} 451 + ) : null} 452 + 453 + {blockDraft.type === "AGREEMENT" ? ( 454 + <label className="grid gap-2 text-sm text-[var(--muted)]"> 455 + <span className="font-medium text-[var(--ink)]">{t("builder.checkboxLabel")}</span> 456 + <Input 457 + value={(blockDraft.config as AgreementBlockConfig).label} 458 + onChange={(event) => updateConfig({ label: event.target.value })} 459 + /> 460 + </label> 461 + ) : null} 358 462 359 463 {(blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE") && ( 360 464 <div className="grid gap-3 text-sm text-[var(--muted)]">
+20 -4
components/form-builder.tsx
··· 17 17 } from "@dnd-kit/sortable"; 18 18 import { CSS } from "@dnd-kit/utilities"; 19 19 import { 20 + BadgeCheck, 21 + CalendarDays, 20 22 GripVertical, 23 + Hash, 24 + Link2, 21 25 LoaderCircle, 22 26 Plus, 23 27 Radio, ··· 56 60 LONG_TEXT: Rows3, 57 61 SINGLE_CHOICE: Radio, 58 62 MULTIPLE_CHOICE: SquareCheck, 63 + NUMBER: Hash, 64 + LINK: Link2, 65 + AGREEMENT: BadgeCheck, 66 + DATE: CalendarDays, 59 67 }; 60 68 61 - const blockCreationOrder: BlockType[] = ["TEXT", "SHORT_TEXT", "LONG_TEXT", "SINGLE_CHOICE", "MULTIPLE_CHOICE"]; 69 + const blockCreationOrder: BlockType[] = [ 70 + "TEXT", 71 + "SHORT_TEXT", 72 + "LONG_TEXT", 73 + "NUMBER", 74 + "LINK", 75 + "DATE", 76 + "SINGLE_CHOICE", 77 + "MULTIPLE_CHOICE", 78 + "AGREEMENT", 79 + ]; 62 80 63 81 function createChoiceOptionDrafts(options: string[]): ChoiceOptionDraft[] { 64 82 return options.map((value) => ({ id: crypto.randomUUID(), value })); ··· 201 219 202 220 const [metadataDraft, setMetadataDraft] = useState<FormMetadataDraft>({ 203 221 title: initialForm.title, 204 - description: initialForm.description, 205 222 completionTitle: initialForm.completionTitle, 206 223 completionMessage: initialForm.completionMessage, 207 224 completionLinkLabel: initialForm.completionLinkLabel ?? "", ··· 231 248 useEffect(() => { 232 249 setMetadataDraft({ 233 250 title: form.title, 234 - description: form.description, 235 251 completionTitle: form.completionTitle, 236 252 completionMessage: form.completionMessage, 237 253 completionLinkLabel: form.completionLinkLabel ?? "", 238 254 completionLinkUrl: form.completionLinkUrl ?? "", 239 255 slug: form.slug, 240 256 }); 241 - }, [form.title, form.description, form.completionTitle, form.completionMessage, form.completionLinkLabel, form.completionLinkUrl, form.slug]); 257 + }, [form.title, form.completionTitle, form.completionMessage, form.completionLinkLabel, form.completionLinkUrl, form.slug]); 242 258 243 259 useEffect(() => { 244 260 setBlockDraft(selectedBlock);
+336 -63
components/public-form-runner.tsx
··· 1 1 "use client"; 2 2 3 3 import { AnimatePresence, motion } from "framer-motion"; 4 - import { ArrowLeft, ArrowRight, Check, Circle, LoaderCircle, Square } from "lucide-react"; 5 - import Image from "next/image"; 4 + import { Check, Circle, CornerDownLeft, LoaderCircle, Square } from "lucide-react"; 6 5 import Link from "next/link"; 7 - import { useMemo, useState } from "react"; 6 + import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; 8 7 9 8 import { useI18n } from "@/components/i18n-provider"; 10 9 import { Button, buttonVariants } from "@/components/ui/button"; 11 10 import { Card } from "@/components/ui/card"; 11 + import { DatePickerInput } from "@/components/ui/date-picker-input"; 12 12 import { Input } from "@/components/ui/input"; 13 13 import { ToastViewport, type ToastData } from "@/components/ui/toast"; 14 14 import { Textarea } from "@/components/ui/textarea"; 15 - import type { ChoiceBlockConfig, LongTextBlockConfig, ShortTextBlockConfig, TextBlockConfig } from "@/lib/blocks"; 15 + import { 16 + AGREEMENT_ANSWER_VALUES, 17 + getTextValidationPattern, 18 + isValidDateAnswer, 19 + isValidLinkAnswer, 20 + parseNumericAnswer, 21 + type ChoiceBlockConfig, 22 + type LinkBlockConfig, 23 + type LongTextBlockConfig, 24 + type NumberBlockConfig, 25 + type ShortTextBlockConfig, 26 + type TextBlockConfig, 27 + } from "@/lib/blocks"; 28 + import { isLegacyDefaultCompletionMessage, isLegacyDefaultCompletionTitle } from "@/lib/form-defaults"; 16 29 import type { PublicForm } from "@/lib/form-types"; 17 30 import { cn } from "@/lib/utils"; 18 31 ··· 37 50 export function PublicFormRunner({ form }: { form: PublicForm }) { 38 51 const [step, setStep] = useState(0); 39 52 const { t } = useI18n(); 40 - const appName = t("app.name"); 41 53 const [answers, setAnswers] = useState<Record<string, string | string[]>>({}); 42 54 const [toasts, setToasts] = useState<ToastData[]>([]); 43 55 const [isSubmitting, setIsSubmitting] = useState(false); ··· 45 57 46 58 const currentBlock = form.blocks[step]; 47 59 const progress = useMemo(() => ((step + 1) / form.blocks.length) * 100, [form.blocks.length, step]); 48 - const completionTitle = form.completionTitle.trim() || t("publicRunner.defaultCompletionTitle"); 60 + const completionTitle = 61 + !form.completionTitle.trim() || isLegacyDefaultCompletionTitle(form.completionTitle) 62 + ? t("publicRunner.defaultCompletionTitle") 63 + : form.completionTitle.trim(); 49 64 const completionMessage = 50 - form.completionMessage.trim() || 51 - t("publicRunner.defaultCompletionMessage"); 65 + !form.completionMessage.trim() || isLegacyDefaultCompletionMessage(form.completionMessage) 66 + ? t("publicRunner.defaultCompletionMessage") 67 + : form.completionMessage.trim(); 52 68 const completionLinkLabel = form.completionLinkLabel?.trim() || null; 53 69 const completionLinkUrl = form.completionLinkUrl?.trim() || null; 54 70 ··· 68 84 setToasts((current) => current.filter((toast) => toast.id !== id)); 69 85 } 70 86 71 - function validateStep() { 87 + function validateStep(answerSet: Record<string, string | string[]> = answers) { 72 88 if (!currentBlock || currentBlock.type === "TEXT") { 73 89 return true; 74 90 } 75 91 76 - const value = answers[currentBlock.id]; 92 + const value = answerSet[currentBlock.id]; 93 + 94 + if (currentBlock.type === "SHORT_TEXT" || currentBlock.type === "LONG_TEXT") { 95 + const textValue = typeof value === "string" ? value.trim() : ""; 77 96 78 - if (!currentBlock.required) { 79 - return true; 80 - } 97 + if (!textValue) { 98 + if (!currentBlock.required) { 99 + return true; 100 + } 81 101 82 - if (currentBlock.type === "SHORT_TEXT" || currentBlock.type === "LONG_TEXT") { 83 - if (typeof value === "string" && value.trim()) { 84 - return true; 102 + showToast(t("publicRunner.answerRequired"), "error"); 103 + return false; 85 104 } 86 105 87 - showToast(t("publicRunner.answerRequired"), "error"); 88 - return false; 106 + const validationRegex = getTextValidationPattern(currentBlock.config as ShortTextBlockConfig | LongTextBlockConfig); 107 + 108 + if (validationRegex && !new RegExp(validationRegex).test(textValue)) { 109 + showToast(t("publicRunner.invalidTextFormat"), "error"); 110 + return false; 111 + } 112 + 113 + return true; 89 114 } 90 115 91 116 if (currentBlock.type === "SINGLE_CHOICE") { ··· 93 118 return true; 94 119 } 95 120 96 - showToast(t("publicRunner.singleChoiceRequired"), "error"); 97 - return false; 121 + if (currentBlock.required) { 122 + showToast(t("publicRunner.singleChoiceRequired"), "error"); 123 + return false; 124 + } 125 + 126 + return true; 98 127 } 99 128 100 129 if (currentBlock.type === "MULTIPLE_CHOICE") { ··· 102 131 return true; 103 132 } 104 133 105 - showToast(t("publicRunner.multiChoiceRequired"), "error"); 106 - return false; 134 + if (currentBlock.required) { 135 + showToast(t("publicRunner.multiChoiceRequired"), "error"); 136 + return false; 137 + } 138 + 139 + return true; 140 + } 141 + 142 + if (currentBlock.type === "NUMBER") { 143 + const textValue = typeof value === "string" ? value.trim() : ""; 144 + const config = currentBlock.config as NumberBlockConfig; 145 + 146 + if (!textValue) { 147 + if (!currentBlock.required) { 148 + return true; 149 + } 150 + 151 + showToast(t("publicRunner.answerRequired"), "error"); 152 + return false; 153 + } 154 + 155 + const numericValue = parseNumericAnswer(textValue); 156 + 157 + if (numericValue === null) { 158 + showToast(t("publicRunner.invalidNumber"), "error"); 159 + return false; 160 + } 161 + 162 + if (!config.allowFloat && !Number.isInteger(numericValue)) { 163 + showToast(t("publicRunner.wholeNumberRequired"), "error"); 164 + return false; 165 + } 166 + 167 + if (config.min !== null && numericValue < config.min) { 168 + showToast(t("publicRunner.numberAtLeast", { min: config.min }), "error"); 169 + return false; 170 + } 171 + 172 + if (config.max !== null && numericValue > config.max) { 173 + showToast(t("publicRunner.numberAtMost", { max: config.max }), "error"); 174 + return false; 175 + } 176 + 177 + return true; 178 + } 179 + 180 + if (currentBlock.type === "LINK") { 181 + const textValue = typeof value === "string" ? value.trim() : ""; 182 + 183 + if (!textValue) { 184 + if (!currentBlock.required) { 185 + return true; 186 + } 187 + 188 + showToast(t("publicRunner.answerRequired"), "error"); 189 + return false; 190 + } 191 + 192 + if (!isValidLinkAnswer(textValue)) { 193 + showToast(t("publicRunner.invalidLink"), "error"); 194 + return false; 195 + } 196 + 197 + return true; 198 + } 199 + 200 + if (currentBlock.type === "DATE") { 201 + const textValue = typeof value === "string" ? value.trim() : ""; 202 + 203 + if (!textValue) { 204 + if (!currentBlock.required) { 205 + return true; 206 + } 207 + 208 + showToast(t("publicRunner.answerRequired"), "error"); 209 + return false; 210 + } 211 + 212 + if (!isValidDateAnswer(textValue)) { 213 + showToast(t("publicRunner.invalidDate"), "error"); 214 + return false; 215 + } 216 + 217 + return true; 218 + } 219 + 220 + if (currentBlock.type === "AGREEMENT") { 221 + const textValue = typeof value === "string" ? value.trim() : ""; 222 + 223 + if (!textValue) { 224 + if (!currentBlock.required) { 225 + return true; 226 + } 227 + 228 + showToast(t("publicRunner.agreementRequired"), "error"); 229 + return false; 230 + } 231 + 232 + if (currentBlock.required && textValue !== AGREEMENT_ANSWER_VALUES.AGREED) { 233 + showToast(t("publicRunner.agreementRequired"), "error"); 234 + return false; 235 + } 236 + 237 + return textValue === AGREEMENT_ANSWER_VALUES.AGREED || textValue === AGREEMENT_ANSWER_VALUES.NOT_AGREED; 107 238 } 108 239 109 240 return true; 110 241 } 111 242 112 - async function handleContinue() { 113 - if (!validateStep()) { 243 + async function handleContinue(answerSet: Record<string, string | string[]> = answers) { 244 + if (!validateStep(answerSet)) { 114 245 return; 115 246 } 116 247 117 248 if (step === form.blocks.length - 1) { 118 249 try { 119 250 setIsSubmitting(true); 120 - await submitResponse(form.slug, answers); 251 + await submitResponse(form.slug, answerSet); 121 252 setIsComplete(true); 122 253 } catch (caughtError) { 123 254 showToast(caughtError instanceof Error ? caughtError.message : t("publicRunner.submitError"), "error"); ··· 135 266 setStep((current) => Math.max(0, current - 1)); 136 267 } 137 268 269 + function handleAdvanceKeyDown( 270 + event: ReactKeyboardEvent<HTMLElement>, 271 + options?: { allowShiftEnter?: boolean }, 272 + ) { 273 + if (event.key !== "Enter" || event.nativeEvent.isComposing || isSubmitting) { 274 + return; 275 + } 276 + 277 + if (options?.allowShiftEnter && event.shiftKey) { 278 + return; 279 + } 280 + 281 + event.preventDefault(); 282 + void handleContinue(); 283 + } 284 + 285 + function handleChoiceEnter( 286 + event: ReactKeyboardEvent<HTMLButtonElement>, 287 + nextAnswer: string | string[], 288 + ) { 289 + if (event.key !== "Enter" || event.nativeEvent.isComposing || isSubmitting) { 290 + return; 291 + } 292 + 293 + event.preventDefault(); 294 + const nextAnswers = { 295 + ...answers, 296 + [currentBlock.id]: nextAnswer, 297 + }; 298 + setAnswers(nextAnswers); 299 + void handleContinue(nextAnswers); 300 + } 301 + 302 + useEffect(() => { 303 + if (isComplete || isSubmitting || !currentBlock) { 304 + return; 305 + } 306 + 307 + function handleWindowKeyDown(event: KeyboardEvent) { 308 + if (event.isComposing || event.metaKey || event.ctrlKey || event.altKey) { 309 + return; 310 + } 311 + 312 + const target = event.target; 313 + 314 + if ( 315 + target instanceof HTMLElement && 316 + (target.tagName === "INPUT" || 317 + target.tagName === "TEXTAREA" || 318 + target.tagName === "BUTTON" || 319 + target.tagName === "SELECT") 320 + ) { 321 + return; 322 + } 323 + 324 + if (event.key === "Enter") { 325 + event.preventDefault(); 326 + void handleContinue(); 327 + return; 328 + } 329 + 330 + if (event.key === "Escape" && step > 0) { 331 + event.preventDefault(); 332 + handleBack(); 333 + } 334 + } 335 + 336 + window.addEventListener("keydown", handleWindowKeyDown); 337 + return () => window.removeEventListener("keydown", handleWindowKeyDown); 338 + }, [currentBlock, handleContinue, isComplete, isSubmitting, step]); 339 + 138 340 if (isComplete) { 139 341 return ( 140 342 <Card className="w-full max-w-3xl overflow-hidden"> 141 343 <div className="border-b border-[color:var(--line)] bg-[var(--accent-soft)]/55 px-6 py-6 sm:px-8"> 142 - <div className="flex items-center gap-4"> 143 - <Image src="/sproute.png" alt={appName} width={44} height={44} priority className="size-11" /> 144 - <div> 145 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("publicRunner.responseReceived")}</p> 146 - <p className="mt-1 text-sm text-[var(--muted)]">{appName}</p> 147 - </div> 148 - </div> 344 + <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("publicRunner.responseReceived")}</p> 149 345 </div> 150 346 <div className="px-6 py-8 sm:px-8 sm:py-10"> 151 347 <h2 className="max-w-2xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl">{completionTitle}</h2> ··· 171 367 <> 172 368 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 173 369 <Card className="w-full max-w-5xl overflow-hidden"> 174 - <div className="border-b border-[color:var(--line)] px-6 py-6 sm:px-8 sm:py-7"> 175 - <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between"> 370 + <div className="border-b border-[color:var(--line)] px-5 py-5 sm:px-6 sm:py-6"> 371 + <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 176 372 <div className="min-w-0"> 177 - <div className="flex items-center gap-4"> 178 - <Image src="/sproute.png" alt={appName} width={44} height={44} priority className="size-11 shrink-0" /> 179 - <div className="min-w-0"> 180 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{appName}</p> 181 - <h1 className="mt-2 font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl">{form.title}</h1> 182 - </div> 183 - </div> 184 - {form.description ? <p className="mt-5 max-w-2xl text-sm leading-7 text-[var(--muted)]">{form.description}</p> : null} 373 + <h1 className="font-display text-2xl leading-tight text-[var(--ink)] sm:text-3xl">{form.title}</h1> 185 374 </div> 186 375 187 - <div className="w-full max-w-sm shrink-0"> 376 + <div className="w-full max-w-xs shrink-0"> 188 377 <div className="flex items-center justify-between gap-3"> 189 378 <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-ink)]"> 190 379 {t("publicRunner.step", { current: step + 1, total: form.blocks.length })} ··· 202 391 </div> 203 392 </div> 204 393 205 - <div className="px-6 py-8 sm:px-8 sm:py-10"> 394 + <div className="px-5 py-6 sm:px-6 sm:py-7"> 206 395 <AnimatePresence mode="wait"> 207 396 <motion.div 208 397 key={currentBlock.id} ··· 210 399 animate={{ opacity: 1, y: 0 }} 211 400 exit={{ opacity: 0, y: -16 }} 212 401 transition={{ duration: 0.24, ease: "easeOut" }} 213 - className="min-h-[360px]" 402 + className="min-h-[280px]" 214 403 > 215 404 {currentBlock.type === "TEXT" ? ( 216 - <div className="flex min-h-[320px] flex-col justify-center rounded-[18px] bg-[var(--bg-strong)]/75 px-6 py-8 sm:px-8"> 217 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("publicRunner.context")}</p> 218 - <h2 className="mt-5 max-w-3xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl"> 405 + <div className="flex min-h-[240px] flex-col justify-center py-4"> 406 + <h2 className="font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl"> 219 407 {currentBlock.title || t("publicRunner.defaultTextTitle")} 220 408 </h2> 221 - {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{currentBlock.description}</p> : null} 222 - <p className="mt-6 max-w-3xl text-lg leading-9 text-[var(--ink)]/82">{(currentBlock.config as TextBlockConfig).body}</p> 409 + <p className="mt-4 whitespace-pre-wrap text-base leading-7 text-[var(--ink)]/82">{(currentBlock.config as TextBlockConfig).body}</p> 223 410 </div> 224 411 ) : ( 225 - <div className="flex min-h-[320px] flex-col justify-center"> 412 + <div className="flex min-h-[240px] flex-col justify-center py-2"> 226 413 <div className="flex flex-wrap items-center gap-3"> 227 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("publicRunner.question")}</p> 228 414 {currentBlock.required ? ( 229 415 <span className="inline-flex items-center rounded-full bg-[var(--accent-soft)] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[var(--accent-ink)]"> 230 416 {t("publicRunner.required")} ··· 232 418 ) : null} 233 419 </div> 234 420 235 - <h2 className="mt-5 max-w-3xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl"> 421 + <h2 className="font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl"> 236 422 {currentBlock.title} 237 423 </h2> 238 - {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{currentBlock.description}</p> : null} 424 + {currentBlock.description ? <p className="mt-3 text-base leading-7 text-[var(--muted)]">{currentBlock.description}</p> : null} 239 425 240 - <div className="mt-10 space-y-4"> 426 + <div className="mt-6 space-y-3"> 241 427 {currentBlock.type === "SINGLE_CHOICE" ? ( 242 428 <p className="text-sm font-medium text-[var(--muted)]">{t("publicRunner.chooseOne")}</p> 243 429 ) : null} ··· 247 433 ) : null} 248 434 {currentBlock.type === "SHORT_TEXT" ? ( 249 435 <Input 250 - className="h-14 text-base placeholder:text-[var(--muted)]/65" 436 + className="h-12 text-base placeholder:text-[var(--muted)]/65" 251 437 placeholder={(currentBlock.config as ShortTextBlockConfig).placeholder} 252 438 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 253 439 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 440 + onKeyDown={handleAdvanceKeyDown} 254 441 /> 255 442 ) : null} 256 443 257 444 {currentBlock.type === "LONG_TEXT" ? ( 258 445 <Textarea 259 - className="min-h-[180px] text-base placeholder:text-[var(--muted)]/65" 446 + className="min-h-[140px] text-base placeholder:text-[var(--muted)]/65" 260 447 placeholder={(currentBlock.config as LongTextBlockConfig).placeholder} 261 448 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 262 449 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 450 + onKeyDown={(event) => handleAdvanceKeyDown(event, { allowShiftEnter: true })} 451 + /> 452 + ) : null} 453 + 454 + {currentBlock.type === "NUMBER" ? ( 455 + <Input 456 + type="number" 457 + step={(currentBlock.config as NumberBlockConfig).allowFloat ? "any" : "1"} 458 + className="h-12 text-base placeholder:text-[var(--muted)]/65" 459 + placeholder={(currentBlock.config as NumberBlockConfig).placeholder} 460 + value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 461 + onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 462 + onKeyDown={handleAdvanceKeyDown} 463 + /> 464 + ) : null} 465 + 466 + {currentBlock.type === "LINK" ? ( 467 + <Input 468 + type="url" 469 + className="h-12 text-base placeholder:text-[var(--muted)]/65" 470 + placeholder={(currentBlock.config as LinkBlockConfig).placeholder} 471 + value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 472 + onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 473 + onKeyDown={handleAdvanceKeyDown} 263 474 /> 264 475 ) : null} 265 476 477 + {currentBlock.type === "DATE" 478 + ? (() => { 479 + const currentValue = answers[currentBlock.id]; 480 + 481 + return ( 482 + <DatePickerInput 483 + className="text-base" 484 + value={typeof currentValue === "string" ? currentValue : ""} 485 + onChange={(value) => setAnswer(currentBlock.id, value)} 486 + onKeyDown={handleAdvanceKeyDown} 487 + openCalendarLabel={t("publicRunner.openCalendar")} 488 + /> 489 + ); 490 + })() 491 + : null} 492 + 266 493 {currentBlock.type === "SINGLE_CHOICE" ? ( 267 494 <div className="grid gap-3"> 268 495 {(currentBlock.config as ChoiceBlockConfig).options.map((option) => { ··· 273 500 key={option} 274 501 type="button" 275 502 className={cn( 276 - "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 503 + "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 277 504 selected 278 505 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 279 506 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 280 507 )} 281 508 onClick={() => setAnswer(currentBlock.id, option)} 509 + onKeyDown={(event) => handleChoiceEnter(event, option)} 282 510 > 283 511 <div className="flex items-center gap-3"> 284 512 <span className="inline-flex size-5 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface)]"> ··· 304 532 key={option} 305 533 type="button" 306 534 className={cn( 307 - "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 535 + "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 308 536 selected 309 537 ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 310 538 : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", ··· 315 543 : [...currentValues, option]; 316 544 setAnswer(currentBlock.id, nextValues); 317 545 }} 546 + onKeyDown={(event) => 547 + handleChoiceEnter( 548 + event, 549 + selected 550 + ? currentValues.filter((value) => value !== option) 551 + : [...currentValues, option], 552 + ) 553 + } 318 554 > 319 555 <div className="flex items-center gap-3"> 320 556 <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> ··· 327 563 })} 328 564 </div> 329 565 ) : null} 566 + 567 + {currentBlock.type === "AGREEMENT" ? ( 568 + <div className="grid gap-3"> 569 + {(() => { 570 + const selected = answers[currentBlock.id] === AGREEMENT_ANSWER_VALUES.AGREED; 571 + const label = "label" in currentBlock.config ? currentBlock.config.label : t("publicRunner.agree"); 572 + const nextValue = selected ? "" : AGREEMENT_ANSWER_VALUES.AGREED; 573 + 574 + return ( 575 + <button 576 + type="button" 577 + className={cn( 578 + "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 579 + selected 580 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 581 + : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 582 + )} 583 + onClick={() => setAnswer(currentBlock.id, nextValue)} 584 + onKeyDown={(event) => handleChoiceEnter(event, nextValue)} 585 + > 586 + <div className="flex items-center gap-3"> 587 + <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> 588 + {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Square className="size-3.5 text-[var(--muted)]/55" />} 589 + </span> 590 + <span>{label}</span> 591 + </div> 592 + </button> 593 + ); 594 + })()} 595 + </div> 596 + ) : null} 330 597 </div> 331 598 </div> 332 599 )} 333 600 </motion.div> 334 601 </AnimatePresence> 335 602 336 - <div className="mt-8 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 603 + <div className="mt-6 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-4"> 337 604 <Button variant="secondary" onClick={handleBack} disabled={step === 0 || isSubmitting}> 338 - <ArrowLeft className="size-4" /> 339 605 {t("publicRunner.back")} 606 + <span className="ml-1 inline-flex h-5 items-center rounded-md border border-current/20 bg-black/10 px-1.5 text-[10px] font-medium opacity-90 dark:bg-white/10"> 607 + Esc 608 + </span> 340 609 </Button> 341 - <Button onClick={handleContinue} disabled={isSubmitting}> 610 + <Button onClick={() => void handleContinue()} disabled={isSubmitting}> 342 611 {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null} 343 612 {step === form.blocks.length - 1 ? t("publicRunner.submit") : t("publicRunner.continue")} 344 - {!isSubmitting ? <ArrowRight className="size-4" /> : null} 613 + {!isSubmitting ? ( 614 + <span className="ml-1 inline-flex h-5 items-center gap-1 rounded-md border border-current/20 bg-black/10 px-1.5 text-[10px] opacity-90 dark:bg-white/10"> 615 + <CornerDownLeft className="size-3" /> 616 + </span> 617 + ) : null} 345 618 </Button> 346 619 </div> 347 620 </div>
+109
components/ui/calendar.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; 5 + import { DayPicker, type DayButton, getDefaultClassNames } from "react-day-picker"; 6 + 7 + import { Button, buttonVariants } from "@/components/ui/button"; 8 + import { cn } from "@/lib/utils"; 9 + 10 + export function Calendar({ 11 + className, 12 + classNames, 13 + showOutsideDays = true, 14 + captionLayout = "label", 15 + ...props 16 + }: React.ComponentProps<typeof DayPicker>) { 17 + const defaultClassNames = getDefaultClassNames(); 18 + 19 + return ( 20 + <DayPicker 21 + showOutsideDays={showOutsideDays} 22 + captionLayout={captionLayout} 23 + className={cn("bg-transparent p-1", className)} 24 + classNames={{ 25 + root: cn("w-fit", defaultClassNames.root), 26 + months: cn("relative flex flex-col gap-4", defaultClassNames.months), 27 + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), 28 + nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav), 29 + button_previous: cn( 30 + buttonVariants({ variant: "ghost", size: "icon" }), 31 + "size-9 rounded-xl border border-[color:var(--line)] bg-[var(--surface)] text-[var(--muted)] shadow-none hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 32 + defaultClassNames.button_previous, 33 + ), 34 + button_next: cn( 35 + buttonVariants({ variant: "ghost", size: "icon" }), 36 + "size-9 rounded-xl border border-[color:var(--line)] bg-[var(--surface)] text-[var(--muted)] shadow-none hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 37 + defaultClassNames.button_next, 38 + ), 39 + month_caption: cn("flex h-9 w-full items-center justify-center px-10", defaultClassNames.month_caption), 40 + dropdowns: cn("flex h-9 w-full items-center justify-center gap-2 text-sm font-semibold text-[var(--ink)]", defaultClassNames.dropdowns), 41 + dropdown_root: cn("relative rounded-xl border border-[color:var(--line)] bg-[var(--surface)]", defaultClassNames.dropdown_root), 42 + dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), 43 + caption_label: cn("text-sm font-semibold text-[var(--ink)]", defaultClassNames.caption_label), 44 + table: "w-full border-collapse", 45 + weekdays: cn("flex gap-1", defaultClassNames.weekdays), 46 + weekday: cn( 47 + "flex-1 rounded-xl py-2 text-center text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--muted)]", 48 + defaultClassNames.weekday, 49 + ), 50 + week: cn("mt-1 flex w-full gap-1", defaultClassNames.week), 51 + day: cn("relative h-10 w-10 p-0 text-center", defaultClassNames.day), 52 + today: cn("rounded-xl ring-1 ring-[color:var(--line-strong)]", defaultClassNames.today), 53 + outside: cn("text-[var(--muted)]/45", defaultClassNames.outside), 54 + disabled: cn("pointer-events-none opacity-40", defaultClassNames.disabled), 55 + hidden: cn("invisible", defaultClassNames.hidden), 56 + ...classNames, 57 + }} 58 + components={{ 59 + Chevron: ({ className: iconClassName, orientation, ...iconProps }) => { 60 + if (orientation === "left") { 61 + return <ChevronLeft className={cn("size-4", iconClassName)} {...iconProps} />; 62 + } 63 + 64 + if (orientation === "right") { 65 + return <ChevronRight className={cn("size-4", iconClassName)} {...iconProps} />; 66 + } 67 + 68 + return <ChevronDown className={cn("size-4", iconClassName)} {...iconProps} />; 69 + }, 70 + DayButton: CalendarDayButton, 71 + }} 72 + {...props} 73 + /> 74 + ); 75 + } 76 + 77 + function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) { 78 + const defaultClassNames = getDefaultClassNames(); 79 + const ref = React.useRef<HTMLButtonElement>(null); 80 + 81 + React.useEffect(() => { 82 + if (modifiers.focused) { 83 + ref.current?.focus(); 84 + } 85 + }, [modifiers.focused]); 86 + 87 + return ( 88 + <Button 89 + ref={ref} 90 + variant="ghost" 91 + size="icon" 92 + data-day={day.date.toLocaleDateString()} 93 + className={cn( 94 + "size-10 rounded-xl text-sm font-medium shadow-none focus-visible:ring-[var(--accent)] focus-visible:ring-offset-0", 95 + modifiers.selected 96 + ? "bg-[var(--accent)] text-white hover:bg-[var(--accent)] hover:text-white" 97 + : "bg-transparent text-[var(--ink)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 98 + modifiers.today && !modifiers.selected ? "border border-[color:var(--line-strong)]" : "border border-transparent", 99 + modifiers.outside ? "text-[var(--muted)]/45 hover:text-[var(--muted)]" : null, 100 + modifiers.disabled ? "pointer-events-none opacity-40" : null, 101 + defaultClassNames.day, 102 + className, 103 + )} 104 + {...props} 105 + /> 106 + ); 107 + } 108 + 109 + export { CalendarDayButton };
+87
components/ui/date-picker-input.tsx
··· 1 + "use client"; 2 + 3 + import { CalendarIcon } from "lucide-react"; 4 + import { useMemo, useState, type InputHTMLAttributes, type KeyboardEventHandler } from "react"; 5 + 6 + import { Calendar } from "@/components/ui/calendar"; 7 + import { Button } from "@/components/ui/button"; 8 + import { Input } from "@/components/ui/input"; 9 + import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 10 + import { isValidDateAnswer } from "@/lib/blocks"; 11 + import { cn } from "@/lib/utils"; 12 + 13 + type DatePickerInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & { 14 + value: string; 15 + onChange: (value: string) => void; 16 + onKeyDown?: KeyboardEventHandler<HTMLInputElement>; 17 + openCalendarLabel: string; 18 + }; 19 + 20 + function parseDateValue(value: string) { 21 + if (!isValidDateAnswer(value)) { 22 + return undefined; 23 + } 24 + 25 + const [year, month, day] = value.split("-").map(Number); 26 + return new Date(year, month - 1, day); 27 + } 28 + 29 + function formatDateValue(date: Date) { 30 + const year = date.getFullYear(); 31 + const month = String(date.getMonth() + 1).padStart(2, "0"); 32 + const day = String(date.getDate()).padStart(2, "0"); 33 + return `${year}-${month}-${day}`; 34 + } 35 + 36 + export function DatePickerInput({ className, onChange, onKeyDown, openCalendarLabel, value, ...props }: DatePickerInputProps) { 37 + const [open, setOpen] = useState(false); 38 + const selectedDate = useMemo(() => parseDateValue(value), [value]); 39 + const [visibleMonth, setVisibleMonth] = useState<Date>(selectedDate ?? new Date()); 40 + 41 + return ( 42 + <div className="relative"> 43 + <Input 44 + {...props} 45 + type="text" 46 + inputMode="numeric" 47 + autoComplete="off" 48 + maxLength={10} 49 + placeholder="YYYY-MM-DD" 50 + value={value} 51 + onChange={(event) => onChange(event.target.value)} 52 + onKeyDown={onKeyDown} 53 + className={cn("h-12 pr-14 text-base tabular-nums placeholder:text-[var(--muted)]/65", className)} 54 + /> 55 + <Popover open={open} onOpenChange={setOpen}> 56 + <PopoverTrigger asChild> 57 + <Button 58 + variant="ghost" 59 + size="icon" 60 + type="button" 61 + aria-label={openCalendarLabel} 62 + className="absolute top-1.5 right-1.5 size-9 rounded-lg border border-[color:var(--line)] bg-[var(--surface)] text-[var(--muted)] shadow-none hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]" 63 + > 64 + <CalendarIcon className="size-4" /> 65 + </Button> 66 + </PopoverTrigger> 67 + <PopoverContent align="end" className="w-auto p-2"> 68 + <Calendar 69 + mode="single" 70 + month={selectedDate ?? visibleMonth} 71 + selected={selectedDate} 72 + onMonthChange={setVisibleMonth} 73 + onSelect={(date) => { 74 + if (!date) { 75 + return; 76 + } 77 + 78 + setVisibleMonth(date); 79 + onChange(formatDateValue(date)); 80 + setOpen(false); 81 + }} 82 + /> 83 + </PopoverContent> 84 + </Popover> 85 + </div> 86 + ); 87 + }
+33
components/ui/popover.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import * as PopoverPrimitive from "@radix-ui/react-popover"; 5 + 6 + import { cn } from "@/lib/utils"; 7 + 8 + const Popover = PopoverPrimitive.Root; 9 + const PopoverTrigger = PopoverPrimitive.Trigger; 10 + const PopoverAnchor = PopoverPrimitive.Anchor; 11 + 12 + const PopoverContent = React.forwardRef< 13 + React.ElementRef<typeof PopoverPrimitive.Content>, 14 + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> 15 + >(({ className, align = "center", sideOffset = 8, ...props }, ref) => { 16 + return ( 17 + <PopoverPrimitive.Portal> 18 + <PopoverPrimitive.Content 19 + ref={ref} 20 + align={align} 21 + sideOffset={sideOffset} 22 + className={cn( 23 + "z-50 w-72 rounded-[20px] border border-[color:var(--line)] bg-[var(--surface-strong)] p-3 text-[var(--ink)] shadow-[var(--shadow-elevated)] outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", 24 + className, 25 + )} 26 + {...props} 27 + /> 28 + </PopoverPrimitive.Portal> 29 + ); 30 + }); 31 + PopoverContent.displayName = PopoverPrimitive.Content.displayName; 32 + 33 + export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+260 -20
lib/blocks.ts
··· 1 - import { FormBlockType, type FormBlock } from "@prisma/client"; 1 + import type { FormBlock, FormBlockType as PrismaFormBlockType } from "@prisma/client"; 2 2 import { z } from "zod"; 3 3 4 4 import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; 5 5 6 - export const blockTypeLabels: Record<FormBlockType, string> = { 6 + const FORM_BLOCK_TYPES = { 7 + TEXT: "TEXT", 8 + SHORT_TEXT: "SHORT_TEXT", 9 + LONG_TEXT: "LONG_TEXT", 10 + SINGLE_CHOICE: "SINGLE_CHOICE", 11 + MULTIPLE_CHOICE: "MULTIPLE_CHOICE", 12 + NUMBER: "NUMBER", 13 + LINK: "LINK", 14 + AGREEMENT: "AGREEMENT", 15 + DATE: "DATE", 16 + } as const satisfies Record<PrismaFormBlockType, PrismaFormBlockType>; 17 + 18 + export const blockTypeValues = [ 19 + FORM_BLOCK_TYPES.TEXT, 20 + FORM_BLOCK_TYPES.SHORT_TEXT, 21 + FORM_BLOCK_TYPES.LONG_TEXT, 22 + FORM_BLOCK_TYPES.SINGLE_CHOICE, 23 + FORM_BLOCK_TYPES.MULTIPLE_CHOICE, 24 + FORM_BLOCK_TYPES.NUMBER, 25 + FORM_BLOCK_TYPES.LINK, 26 + FORM_BLOCK_TYPES.AGREEMENT, 27 + FORM_BLOCK_TYPES.DATE, 28 + ] as const; 29 + 30 + export const blockTypeSchema = z.enum(blockTypeValues); 31 + 32 + export const blockTypeLabels: Record<PrismaFormBlockType, string> = { 7 33 TEXT: "Text block", 8 34 SHORT_TEXT: "Short text", 9 35 LONG_TEXT: "Long text", 10 36 SINGLE_CHOICE: "Single choice", 11 37 MULTIPLE_CHOICE: "Multiple choice", 38 + NUMBER: "Number", 39 + LINK: "Link", 40 + AGREEMENT: "Agreement", 41 + DATE: "Date", 12 42 }; 13 43 14 - export const blockTypeTranslationKeys: Record<FormBlockType, string> = { 44 + export const blockTypeTranslationKeys: Record<PrismaFormBlockType, string> = { 15 45 TEXT: "blocks.types.TEXT", 16 46 SHORT_TEXT: "blocks.types.SHORT_TEXT", 17 47 LONG_TEXT: "blocks.types.LONG_TEXT", 18 48 SINGLE_CHOICE: "blocks.types.SINGLE_CHOICE", 19 49 MULTIPLE_CHOICE: "blocks.types.MULTIPLE_CHOICE", 50 + NUMBER: "blocks.types.NUMBER", 51 + LINK: "blocks.types.LINK", 52 + AGREEMENT: "blocks.types.AGREEMENT", 53 + DATE: "blocks.types.DATE", 20 54 }; 21 55 22 - const defaultBlockCopyByLocale: Record<AppLocale, { textBody: string; shortPlaceholder: string; longPlaceholder: string; options: [string, string] }> = { 56 + const defaultBlockCopyByLocale: Record< 57 + AppLocale, 58 + { 59 + textBody: string; 60 + shortPlaceholder: string; 61 + longPlaceholder: string; 62 + numberPlaceholder: string; 63 + linkPlaceholder: string; 64 + agreementLabel: string; 65 + options: [string, string]; 66 + } 67 + > = { 23 68 en: { 24 69 textBody: "Introduce the next part of your form.", 25 70 shortPlaceholder: "Type your answer here…", 26 71 longPlaceholder: "Tell us a little more…", 72 + numberPlaceholder: "42", 73 + linkPlaceholder: "https://example.com", 74 + agreementLabel: "I agree", 27 75 options: ["Option 1", "Option 2"], 28 76 }, 29 77 ru: { 30 78 textBody: "Подготовьте человека к следующей части формы.", 31 79 shortPlaceholder: "Введите ответ…", 32 80 longPlaceholder: "Расскажите немного подробнее…", 81 + numberPlaceholder: "42", 82 + linkPlaceholder: "https://example.com", 83 + agreementLabel: "Я согласен", 33 84 options: ["Вариант 1", "Вариант 2"], 34 85 }, 35 86 }; ··· 38 89 return defaultBlockCopyByLocale[locale] ?? defaultBlockCopyByLocale[DEFAULT_LOCALE]; 39 90 } 40 91 92 + function normalizeRegexPattern(value: unknown) { 93 + if (typeof value !== "string") { 94 + return null; 95 + } 96 + 97 + const pattern = value.trim(); 98 + return pattern || null; 99 + } 100 + 101 + const regexPatternSchema = z 102 + .unknown() 103 + .transform(normalizeRegexPattern) 104 + .superRefine((value, context) => { 105 + if (!value) { 106 + return; 107 + } 108 + 109 + try { 110 + new RegExp(value); 111 + } catch { 112 + context.addIssue({ 113 + code: z.ZodIssueCode.custom, 114 + message: "Use a valid regex pattern.", 115 + }); 116 + } 117 + }); 118 + 119 + const numericLimitSchema = z 120 + .unknown() 121 + .transform((value) => { 122 + if (value === null || typeof value === "undefined") { 123 + return null; 124 + } 125 + 126 + if (typeof value === "number") { 127 + return Number.isFinite(value) ? value : Number.NaN; 128 + } 129 + 130 + if (typeof value === "string") { 131 + const trimmed = value.trim(); 132 + 133 + if (!trimmed) { 134 + return null; 135 + } 136 + 137 + const nextValue = Number(trimmed); 138 + return Number.isFinite(nextValue) ? nextValue : Number.NaN; 139 + } 140 + 141 + return Number.NaN; 142 + }) 143 + .refine((value) => value === null || Number.isFinite(value), { 144 + message: "Use a valid number.", 145 + }); 146 + 41 147 const textConfigSchema = z.object({ 42 148 body: z.string().max(2400).default(defaultBlockCopyByLocale.en.textBody), 43 149 }); 44 150 45 151 const shortTextConfigSchema = z.object({ 46 152 placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.shortPlaceholder), 153 + validationRegex: regexPatternSchema.default(null), 47 154 }); 48 155 49 156 const longTextConfigSchema = z.object({ 50 157 placeholder: z.string().max(240).default(defaultBlockCopyByLocale.en.longPlaceholder), 158 + validationRegex: regexPatternSchema.default(null), 51 159 }); 52 160 161 + const numberConfigSchema = z 162 + .object({ 163 + placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.numberPlaceholder), 164 + allowFloat: z.boolean().default(false), 165 + min: numericLimitSchema.default(null), 166 + max: numericLimitSchema.default(null), 167 + }) 168 + .superRefine((value, context) => { 169 + if (!value.allowFloat) { 170 + if (value.min !== null && !Number.isInteger(value.min)) { 171 + context.addIssue({ 172 + code: z.ZodIssueCode.custom, 173 + path: ["min"], 174 + message: "Use a whole number when floats are disabled.", 175 + }); 176 + } 177 + 178 + if (value.max !== null && !Number.isInteger(value.max)) { 179 + context.addIssue({ 180 + code: z.ZodIssueCode.custom, 181 + path: ["max"], 182 + message: "Use a whole number when floats are disabled.", 183 + }); 184 + } 185 + } 186 + 187 + if (value.min !== null && value.max !== null && value.min > value.max) { 188 + context.addIssue({ 189 + code: z.ZodIssueCode.custom, 190 + path: ["max"], 191 + message: "Maximum must be greater than or equal to minimum.", 192 + }); 193 + } 194 + }); 195 + 196 + const linkConfigSchema = z.object({ 197 + placeholder: z.string().max(2048).default(defaultBlockCopyByLocale.en.linkPlaceholder), 198 + }); 199 + 200 + const agreementConfigSchema = z.object({ 201 + label: z.string().trim().max(160).default(defaultBlockCopyByLocale.en.agreementLabel), 202 + }); 203 + 204 + const dateConfigSchema = z.object({}); 205 + 53 206 const optionListSchema = z.object({ 54 207 options: z.array(z.string().min(1).max(120)).min(2).max(10).default(defaultBlockCopyByLocale.en.options), 55 208 }); 56 209 210 + export const AGREEMENT_ANSWER_VALUES = { 211 + AGREED: "agreed", 212 + NOT_AGREED: "not_agreed", 213 + } as const; 214 + 215 + export type AgreementAnswerValue = (typeof AGREEMENT_ANSWER_VALUES)[keyof typeof AGREEMENT_ANSWER_VALUES]; 57 216 export type TextBlockConfig = z.infer<typeof textConfigSchema>; 58 217 export type ShortTextBlockConfig = z.infer<typeof shortTextConfigSchema>; 59 218 export type LongTextBlockConfig = z.infer<typeof longTextConfigSchema>; 219 + export type NumberBlockConfig = z.infer<typeof numberConfigSchema>; 220 + export type LinkBlockConfig = z.infer<typeof linkConfigSchema>; 221 + export type AgreementBlockConfig = z.infer<typeof agreementConfigSchema>; 222 + export type DateBlockConfig = z.infer<typeof dateConfigSchema>; 60 223 export type ChoiceBlockConfig = z.infer<typeof optionListSchema>; 224 + export type TextAnswerBlockConfig = ShortTextBlockConfig | LongTextBlockConfig; 61 225 export type BlockConfig = 62 226 | TextBlockConfig 63 227 | ShortTextBlockConfig 64 228 | LongTextBlockConfig 229 + | NumberBlockConfig 230 + | LinkBlockConfig 231 + | AgreementBlockConfig 232 + | DateBlockConfig 65 233 | ChoiceBlockConfig; 66 234 67 235 export type SerializedBlock = Omit<FormBlock, "config"> & { 68 236 config: BlockConfig; 69 237 }; 70 238 71 - export function isQuestionBlock(type: FormBlockType) { 72 - return type !== FormBlockType.TEXT; 239 + export function isQuestionBlock(type: PrismaFormBlockType) { 240 + return type !== FORM_BLOCK_TYPES.TEXT; 241 + } 242 + 243 + export function isTextAnswerBlock(type: PrismaFormBlockType) { 244 + return type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT; 245 + } 246 + 247 + export function isChoiceBlock(type: PrismaFormBlockType) { 248 + return type === FORM_BLOCK_TYPES.SINGLE_CHOICE || type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE; 249 + } 250 + 251 + export function getTextValidationPattern(config: TextAnswerBlockConfig) { 252 + return config.validationRegex ?? null; 253 + } 254 + 255 + export function isAgreementAnswerValue(value: string): value is AgreementAnswerValue { 256 + return value === AGREEMENT_ANSWER_VALUES.AGREED || value === AGREEMENT_ANSWER_VALUES.NOT_AGREED; 257 + } 258 + 259 + export function parseNumericAnswer(value: string) { 260 + const trimmed = value.trim(); 261 + 262 + if (!trimmed) { 263 + return null; 264 + } 265 + 266 + const parsed = Number(trimmed); 267 + return Number.isFinite(parsed) ? parsed : null; 73 268 } 74 269 75 - export function getDefaultBlockConfig(type: FormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig { 270 + export function isValidLinkAnswer(value: string) { 271 + try { 272 + const parsed = new URL(value); 273 + return parsed.protocol === "http:" || parsed.protocol === "https:"; 274 + } catch { 275 + return false; 276 + } 277 + } 278 + 279 + export function isValidDateAnswer(value: string) { 280 + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { 281 + return false; 282 + } 283 + 284 + const date = new Date(`${value}T00:00:00.000Z`); 285 + return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; 286 + } 287 + 288 + export function getDefaultBlockConfig(type: PrismaFormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig { 76 289 const copy = getDefaultBlockCopy(locale); 77 290 78 291 switch (type) { 79 - case FormBlockType.TEXT: 292 + case FORM_BLOCK_TYPES.TEXT: 80 293 return textConfigSchema.parse({ body: copy.textBody }); 81 - case FormBlockType.SHORT_TEXT: 294 + case FORM_BLOCK_TYPES.SHORT_TEXT: 82 295 return shortTextConfigSchema.parse({ placeholder: copy.shortPlaceholder }); 83 - case FormBlockType.LONG_TEXT: 296 + case FORM_BLOCK_TYPES.LONG_TEXT: 84 297 return longTextConfigSchema.parse({ placeholder: copy.longPlaceholder }); 85 - case FormBlockType.SINGLE_CHOICE: 86 - case FormBlockType.MULTIPLE_CHOICE: 298 + case FORM_BLOCK_TYPES.SINGLE_CHOICE: 299 + case FORM_BLOCK_TYPES.MULTIPLE_CHOICE: 87 300 return optionListSchema.parse({ options: copy.options }); 301 + case FORM_BLOCK_TYPES.NUMBER: 302 + return numberConfigSchema.parse({ placeholder: copy.numberPlaceholder }); 303 + case FORM_BLOCK_TYPES.LINK: 304 + return linkConfigSchema.parse({ placeholder: copy.linkPlaceholder }); 305 + case FORM_BLOCK_TYPES.AGREEMENT: 306 + return agreementConfigSchema.parse({ label: copy.agreementLabel }); 307 + case FORM_BLOCK_TYPES.DATE: 308 + return dateConfigSchema.parse({}); 88 309 default: 89 310 return textConfigSchema.parse({ body: copy.textBody }); 90 311 } 91 312 } 92 313 93 - export function parseBlockConfig(type: FormBlockType, value: unknown): BlockConfig { 314 + export function parseBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig { 94 315 switch (type) { 95 - case FormBlockType.TEXT: 316 + case FORM_BLOCK_TYPES.TEXT: 96 317 return textConfigSchema.parse(value ?? {}); 97 - case FormBlockType.SHORT_TEXT: 318 + case FORM_BLOCK_TYPES.SHORT_TEXT: 98 319 return shortTextConfigSchema.parse(value ?? {}); 99 - case FormBlockType.LONG_TEXT: 320 + case FORM_BLOCK_TYPES.LONG_TEXT: 100 321 return longTextConfigSchema.parse(value ?? {}); 101 - case FormBlockType.SINGLE_CHOICE: 102 - case FormBlockType.MULTIPLE_CHOICE: 322 + case FORM_BLOCK_TYPES.SINGLE_CHOICE: 323 + case FORM_BLOCK_TYPES.MULTIPLE_CHOICE: 103 324 return optionListSchema.parse(value ?? {}); 325 + case FORM_BLOCK_TYPES.NUMBER: 326 + return numberConfigSchema.parse(value ?? {}); 327 + case FORM_BLOCK_TYPES.LINK: 328 + return linkConfigSchema.parse(value ?? {}); 329 + case FORM_BLOCK_TYPES.AGREEMENT: 330 + return agreementConfigSchema.parse(value ?? {}); 331 + case FORM_BLOCK_TYPES.DATE: 332 + return dateConfigSchema.parse(value ?? {}); 104 333 default: 105 334 return textConfigSchema.parse(value ?? {}); 106 335 } ··· 120 349 return block.title; 121 350 } 122 351 123 - if (block.type === FormBlockType.TEXT && "body" in config) { 352 + if (block.type === FORM_BLOCK_TYPES.TEXT && "body" in config) { 124 353 return config.body; 125 354 } 126 355 127 - if ("placeholder" in config && config.placeholder.trim()) { 356 + if ( 357 + (block.type === FORM_BLOCK_TYPES.SHORT_TEXT || 358 + block.type === FORM_BLOCK_TYPES.LONG_TEXT || 359 + block.type === FORM_BLOCK_TYPES.NUMBER || 360 + block.type === FORM_BLOCK_TYPES.LINK) && 361 + "placeholder" in config && 362 + config.placeholder.trim() 363 + ) { 128 364 return config.placeholder; 129 365 } 130 366 131 367 if ("options" in config) { 132 368 return config.options.join(" • "); 369 + } 370 + 371 + if (block.type === FORM_BLOCK_TYPES.AGREEMENT && "label" in config && config.label.trim()) { 372 + return config.label; 133 373 } 134 374 135 375 return block.description || fallbackLabel || blockTypeLabels[block.type];
+32
lib/form-defaults.ts
··· 1 + import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; 2 + 3 + export const legacyCompletionDefaults = { 4 + title: "Thanks for taking the time.", 5 + message: 6 + "Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.", 7 + } as const; 8 + 9 + export const completionDefaultsByLocale: Record<AppLocale, { title: string; message: string }> = { 10 + en: { 11 + title: "Thanks for taking the time.", 12 + message: 13 + "Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.", 14 + }, 15 + ru: { 16 + title: "Спасибо, что уделили время.", 17 + message: 18 + "Ваш ответ был отправлен анонимно. Создатель формы может просмотреть ответы, но они не связаны с вашим аккаунтом или входом в систему.", 19 + }, 20 + }; 21 + 22 + export function getLocalizedCompletionDefaults(locale: AppLocale = DEFAULT_LOCALE) { 23 + return completionDefaultsByLocale[locale] ?? completionDefaultsByLocale[DEFAULT_LOCALE]; 24 + } 25 + 26 + export function isLegacyDefaultCompletionTitle(value: string | null | undefined) { 27 + return (value ?? "").trim() === legacyCompletionDefaults.title; 28 + } 29 + 30 + export function isLegacyDefaultCompletionMessage(value: string | null | undefined) { 31 + return (value ?? "").trim() === legacyCompletionDefaults.message; 32 + }
+167 -25
lib/forms.ts
··· 1 1 import { 2 - FormBlockType, 3 2 FormStatus, 4 3 type Form, 5 4 type FormBlock, 5 + type FormBlockType as PrismaFormBlockType, 6 6 type Prisma, 7 7 } from "@prisma/client"; 8 8 import { z } from "zod"; 9 9 10 10 import { 11 + AGREEMENT_ANSWER_VALUES, 12 + blockTypeSchema, 11 13 getDefaultBlockConfig, 14 + isAgreementAnswerValue, 15 + isChoiceBlock, 12 16 isQuestionBlock, 17 + isTextAnswerBlock, 18 + isValidDateAnswer, 19 + isValidLinkAnswer, 13 20 parseBlockConfig, 21 + parseNumericAnswer, 14 22 serializeBlock, 15 23 type BlockConfig, 24 + type ChoiceBlockConfig, 25 + type LinkBlockConfig, 26 + type NumberBlockConfig, 16 27 type SerializedBlock, 28 + type TextAnswerBlockConfig, 17 29 } from "@/lib/blocks"; 18 30 import { db } from "@/lib/db"; 19 31 import { AppError } from "@/lib/errors"; 32 + import { getLocalizedCompletionDefaults } from "@/lib/form-defaults"; 20 33 import type { 21 34 ActiveWorkspace, 22 35 BuilderForm, ··· 38 51 import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; 39 52 import { assertOrganizationMember, workspaceAccessWhere, workspaceFilterWhere } from "@/lib/workspaces"; 40 53 54 + const FORM_BLOCK_TYPES = { 55 + TEXT: "TEXT", 56 + SHORT_TEXT: "SHORT_TEXT", 57 + LONG_TEXT: "LONG_TEXT", 58 + SINGLE_CHOICE: "SINGLE_CHOICE", 59 + MULTIPLE_CHOICE: "MULTIPLE_CHOICE", 60 + NUMBER: "NUMBER", 61 + LINK: "LINK", 62 + AGREEMENT: "AGREEMENT", 63 + DATE: "DATE", 64 + } as const satisfies Record<PrismaFormBlockType, PrismaFormBlockType>; 65 + 41 66 const builderFormInclude = { 42 67 organization: { 43 68 select: { ··· 83 108 84 109 const snapshotBlockSchema = z.object({ 85 110 id: z.string(), 86 - type: z.nativeEnum(FormBlockType), 111 + type: blockTypeSchema, 87 112 title: z.string(), 88 113 description: z.string(), 89 114 required: z.boolean(), ··· 148 173 149 174 const defaultFormCopyByLocale: Record<AppLocale, { 150 175 untitledForm: string; 176 + completionTitle: string; 177 + completionMessage: string; 151 178 initialBlocks: Prisma.FormBlockCreateWithoutFormInput[]; 152 - newBlockTitles: Record<FormBlockType, string>; 179 + newBlockTitles: Record<PrismaFormBlockType, string>; 153 180 }> = { 154 181 en: { 155 182 untitledForm: "Untitled form", 183 + completionTitle: getLocalizedCompletionDefaults("en").title, 184 + completionMessage: getLocalizedCompletionDefaults("en").message, 156 185 initialBlocks: [ 157 186 { 158 - type: FormBlockType.TEXT, 187 + type: FORM_BLOCK_TYPES.TEXT, 159 188 title: "Welcome", 160 189 description: "Set the scene before people start answering.", 161 190 position: 0, 162 191 required: false, 163 - config: getDefaultBlockConfig(FormBlockType.TEXT, "en"), 192 + config: getDefaultBlockConfig(FORM_BLOCK_TYPES.TEXT, "en"), 164 193 }, 165 194 { 166 - type: FormBlockType.SHORT_TEXT, 195 + type: FORM_BLOCK_TYPES.SHORT_TEXT, 167 196 title: "What should we call you?", 168 197 description: "A simple first question keeps the flow moving.", 169 198 position: 1, 170 199 required: true, 171 - config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT, "en"), 200 + config: getDefaultBlockConfig(FORM_BLOCK_TYPES.SHORT_TEXT, "en"), 172 201 }, 173 202 ], 174 203 newBlockTitles: { ··· 177 206 LONG_TEXT: "Long text question", 178 207 SINGLE_CHOICE: "Single choice question", 179 208 MULTIPLE_CHOICE: "Multiple choice question", 209 + NUMBER: "Number question", 210 + LINK: "Link question", 211 + AGREEMENT: "Agreement question", 212 + DATE: "Date question", 180 213 }, 181 214 }, 182 215 ru: { 183 216 untitledForm: "Форма без названия", 217 + completionTitle: getLocalizedCompletionDefaults("ru").title, 218 + completionMessage: getLocalizedCompletionDefaults("ru").message, 184 219 initialBlocks: [ 185 220 { 186 - type: FormBlockType.TEXT, 221 + type: FORM_BLOCK_TYPES.TEXT, 187 222 title: "Добро пожаловать", 188 223 description: "Задайте тон перед тем, как люди начнут отвечать.", 189 224 position: 0, 190 225 required: false, 191 - config: getDefaultBlockConfig(FormBlockType.TEXT, "ru"), 226 + config: getDefaultBlockConfig(FORM_BLOCK_TYPES.TEXT, "ru"), 192 227 }, 193 228 { 194 - type: FormBlockType.SHORT_TEXT, 229 + type: FORM_BLOCK_TYPES.SHORT_TEXT, 195 230 title: "Как к вам обращаться?", 196 231 description: "Простой первый вопрос помогает сохранить ритм.", 197 232 position: 1, 198 233 required: true, 199 - config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT, "ru"), 234 + config: getDefaultBlockConfig(FORM_BLOCK_TYPES.SHORT_TEXT, "ru"), 200 235 }, 201 236 ], 202 237 newBlockTitles: { ··· 205 240 LONG_TEXT: "Вопрос с длинным ответом", 206 241 SINGLE_CHOICE: "Вопрос с одним выбором", 207 242 MULTIPLE_CHOICE: "Вопрос с несколькими вариантами", 243 + NUMBER: "Вопрос с числом", 244 + LINK: "Вопрос со ссылкой", 245 + AGREEMENT: "Вопрос с согласием", 246 + DATE: "Вопрос с датой", 208 247 }, 209 248 }, 210 249 }; ··· 273 312 .slice(0, 10); 274 313 } 275 314 276 - function normalizeBlockConfig(type: FormBlockType, value: unknown): BlockConfig { 277 - if (type === FormBlockType.SINGLE_CHOICE || type === FormBlockType.MULTIPLE_CHOICE) { 315 + function normalizeBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig { 316 + if (isChoiceBlock(type)) { 278 317 const rawOptions = 279 318 typeof value === "object" && value && "options" in value && Array.isArray(value.options) 280 319 ? value.options.filter((option): option is string => typeof option === "string") 281 320 : []; 282 321 const options = sanitizeOptions(rawOptions); 283 322 284 - return { 323 + return parseBlockConfig(type, { 285 324 options: options.length >= 2 ? options : ["Option 1", "Option 2"], 286 - }; 325 + }); 287 326 } 288 327 289 328 return parseBlockConfig(type, value); 290 329 } 291 330 331 + function getQuestionLabel(block: SerializedBlock) { 332 + return block.title || "this question"; 333 + } 334 + 292 335 function normalizeAnswer(block: SerializedBlock, rawValue: unknown): string | string[] | undefined { 293 336 const config = block.config; 337 + const questionLabel = getQuestionLabel(block); 294 338 295 339 if (!isQuestionBlock(block.type)) { 296 340 return undefined; 297 341 } 298 342 299 - if (block.type === FormBlockType.SHORT_TEXT || block.type === FormBlockType.LONG_TEXT) { 343 + if (isTextAnswerBlock(block.type)) { 300 344 const value = typeof rawValue === "string" ? rawValue.trim() : ""; 345 + const validationRegex = (config as TextAnswerBlockConfig).validationRegex; 301 346 302 347 if (!value) { 303 348 if (block.required) { 304 - throw new AppError(`Please answer “${block.title || "this question"}”.`, 422); 349 + throw new AppError(`Please answer “${questionLabel}”.`, 422); 305 350 } 306 351 307 352 return undefined; 308 353 } 309 354 355 + if (validationRegex && !new RegExp(validationRegex).test(value)) { 356 + throw new AppError(`Please use a valid format for “${questionLabel}”.`, 422); 357 + } 358 + 310 359 return value; 311 360 } 312 361 313 - if (block.type === FormBlockType.SINGLE_CHOICE) { 362 + if (block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE) { 314 363 const value = typeof rawValue === "string" ? rawValue.trim() : ""; 315 - const options = "options" in config ? config.options : []; 364 + const options = (config as ChoiceBlockConfig).options; 316 365 317 366 if (!value) { 318 367 if (block.required) { 319 - throw new AppError(`Please choose an option for “${block.title || "this question"}”.`, 422); 368 + throw new AppError(`Please choose an option for “${questionLabel}”.`, 422); 320 369 } 321 370 322 371 return undefined; 323 372 } 324 373 325 374 if (!options.includes(value)) { 326 - throw new AppError(`Invalid option submitted for “${block.title || "this question"}”.`, 422); 375 + throw new AppError(`Invalid option submitted for “${questionLabel}”.`, 422); 327 376 } 328 377 329 378 return value; 330 379 } 331 380 332 - if (block.type === FormBlockType.MULTIPLE_CHOICE) { 381 + if (block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) { 333 382 const values = Array.isArray(rawValue) 334 383 ? rawValue.filter((value): value is string => typeof value === "string") 335 384 : []; 336 - const options = "options" in config ? config.options : []; 385 + const options = (config as ChoiceBlockConfig).options; 337 386 const uniqueValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))]; 338 387 339 388 if (!uniqueValues.length) { 340 389 if (block.required) { 341 - throw new AppError(`Please choose at least one option for “${block.title || "this question"}”.`, 422); 390 + throw new AppError(`Please choose at least one option for “${questionLabel}”.`, 422); 342 391 } 343 392 344 393 return undefined; 345 394 } 346 395 347 396 if (!uniqueValues.every((value) => options.includes(value))) { 348 - throw new AppError(`Invalid option submitted for “${block.title || "this question"}”.`, 422); 397 + throw new AppError(`Invalid option submitted for “${questionLabel}”.`, 422); 349 398 } 350 399 351 400 return uniqueValues; 352 401 } 353 402 403 + if (block.type === FORM_BLOCK_TYPES.NUMBER) { 404 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 405 + const numberConfig = config as NumberBlockConfig; 406 + 407 + if (!value) { 408 + if (block.required) { 409 + throw new AppError(`Please enter a number for “${questionLabel}”.`, 422); 410 + } 411 + 412 + return undefined; 413 + } 414 + 415 + const numericValue = parseNumericAnswer(value); 416 + 417 + if (numericValue === null) { 418 + throw new AppError(`Please enter a valid number for “${questionLabel}”.`, 422); 419 + } 420 + 421 + if (!numberConfig.allowFloat && !Number.isInteger(numericValue)) { 422 + throw new AppError(`Please enter a whole number for “${questionLabel}”.`, 422); 423 + } 424 + 425 + if (numberConfig.min !== null && numericValue < numberConfig.min) { 426 + throw new AppError(`Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`, 422); 427 + } 428 + 429 + if (numberConfig.max !== null && numericValue > numberConfig.max) { 430 + throw new AppError(`Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`, 422); 431 + } 432 + 433 + return String(numericValue); 434 + } 435 + 436 + if (block.type === FORM_BLOCK_TYPES.LINK) { 437 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 438 + 439 + if (!value) { 440 + if (block.required) { 441 + throw new AppError(`Please enter a link for “${questionLabel}”.`, 422); 442 + } 443 + 444 + return undefined; 445 + } 446 + 447 + if (!isValidLinkAnswer(value)) { 448 + throw new AppError(`Please enter a valid link for “${questionLabel}”.`, 422); 449 + } 450 + 451 + return new URL(value).toString(); 452 + } 453 + 454 + if (block.type === FORM_BLOCK_TYPES.DATE) { 455 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 456 + 457 + if (!value) { 458 + if (block.required) { 459 + throw new AppError(`Please enter a date for “${questionLabel}”.`, 422); 460 + } 461 + 462 + return undefined; 463 + } 464 + 465 + if (!isValidDateAnswer(value)) { 466 + throw new AppError(`Please enter a valid date for “${questionLabel}”.`, 422); 467 + } 468 + 469 + return value; 470 + } 471 + 472 + if (block.type === FORM_BLOCK_TYPES.AGREEMENT) { 473 + const value = typeof rawValue === "string" ? rawValue.trim() : ""; 474 + 475 + if (!value) { 476 + if (block.required) { 477 + throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); 478 + } 479 + 480 + return undefined; 481 + } 482 + 483 + if (!isAgreementAnswerValue(value)) { 484 + throw new AppError(`Invalid agreement value submitted for “${questionLabel}”.`, 422); 485 + } 486 + 487 + if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) { 488 + throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); 489 + } 490 + 491 + return value; 492 + } 493 + 354 494 return undefined; 355 495 } 356 496 ··· 429 569 organizationId: workspace.kind === "organization" ? workspace.organizationId : null, 430 570 title: copy.untitledForm, 431 571 description: "", 572 + completionTitle: copy.completionTitle, 573 + completionMessage: copy.completionMessage, 432 574 slug, 433 575 blocks: { 434 576 create: initialBlocks(locale),
+20 -4
lib/response-exports.ts
··· 1 1 import { utils, write } from "xlsx"; 2 2 3 - import { isQuestionBlock, serializeBlock, type SerializedBlock } from "@/lib/blocks"; 3 + import { AGREEMENT_ANSWER_VALUES, isQuestionBlock, serializeBlock, type SerializedBlock } from "@/lib/blocks"; 4 4 import { db } from "@/lib/db"; 5 5 import { AppError } from "@/lib/errors"; 6 6 import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; ··· 32 32 metadataColumns: ResponseExportColumn[]; 33 33 questionFallback: string; 34 34 sheetName: string; 35 + agreed: string; 36 + notAgreed: string; 35 37 }> = { 36 38 en: { 37 39 metadataColumns: [ ··· 41 43 ], 42 44 questionFallback: "Question {number}", 43 45 sheetName: "Responses", 46 + agreed: "Agree", 47 + notAgreed: "Do not agree", 44 48 }, 45 49 ru: { 46 50 metadataColumns: [ ··· 50 54 ], 51 55 questionFallback: "Вопрос {number}", 52 56 sheetName: "Ответы", 57 + agreed: "Согласен", 58 + notAgreed: "Не согласен", 53 59 }, 54 60 }; 55 61 ··· 97 103 return occurrence === 1 ? baseLabel : `${baseLabel} (${occurrence})`; 98 104 } 99 105 100 - export function serializeExportAnswer(value: ExportAnswerValue) { 106 + export function serializeExportAnswer(value: ExportAnswerValue, block?: SerializedBlock, locale: AppLocale = DEFAULT_LOCALE) { 101 107 if (Array.isArray(value)) { 102 108 return value.join(" | "); 103 109 } 104 110 105 - return typeof value === "string" ? value : ""; 111 + if (typeof value !== "string") { 112 + return ""; 113 + } 114 + 115 + if (block?.type === "AGREEMENT") { 116 + const copy = getExportCopy(locale); 117 + return value === AGREEMENT_ANSWER_VALUES.AGREED ? copy.agreed : copy.notAgreed; 118 + } 119 + 120 + return value; 106 121 } 107 122 108 123 export function buildResponseExportDataset(form: OwnedFormExportRecord, locale: AppLocale = DEFAULT_LOCALE): ResponseExportDataset { ··· 118 133 blockId: block.id, 119 134 })), 120 135 ]; 136 + const questionBlocksById = new Map(questionBlocks.map((block) => [block.id, block])); 121 137 122 138 const rows = form.responses.map((response, index) => { 123 139 const answers = ··· 141 157 return accumulator; 142 158 } 143 159 144 - accumulator[column.key] = serializeExportAnswer(answers[column.key]); 160 + accumulator[column.key] = serializeExportAnswer(answers[column.key], questionBlocksById.get(column.key), locale); 145 161 return accumulator; 146 162 }, {}); 147 163
+9
lib/utils.ts
··· 23 23 }).format(date); 24 24 } 25 25 26 + export function formatCalendarDate(value: Date | string, locale = "en") { 27 + const date = typeof value === "string" ? new Date(`${value}T00:00:00.000Z`) : value; 28 + 29 + return new Intl.DateTimeFormat(locale, { 30 + dateStyle: "medium", 31 + timeZone: "UTC", 32 + }).format(date); 33 + } 34 + 26 35 export function sentenceCase(value: string) { 27 36 return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); 28 37 }
+2 -2
lib/validators.ts
··· 1 - import { FormBlockType } from "@prisma/client"; 2 1 import { z } from "zod"; 3 2 3 + import { blockTypeSchema } from "@/lib/blocks"; 4 4 import { supportedLocales } from "@/lib/i18n"; 5 5 6 6 export const formMetadataSchema = z ··· 59 59 })); 60 60 61 61 export const createBlockSchema = z.object({ 62 - type: z.nativeEnum(FormBlockType), 62 + type: blockTypeSchema, 63 63 }); 64 64 65 65 export const blockUpdateSchema = z.object({
+23 -3
locales/en.yml
··· 101 101 noFormsDescription: New forms start as drafts so you can edit before publishing. 102 102 viewGrid: Grid 103 103 viewTable: Table 104 - noDescription: No description yet. 105 104 shareUrl: Share URL 106 105 titleColumn: Title 107 106 statusColumn: Status ··· 118 117 newBlock: New block 119 118 settingsTitle: Settings 120 119 titleField: Title 121 - descriptionField: Description 122 120 shareSlug: Public URL address 123 121 afterSubmission: After submission 124 122 afterSubmissionDescription: Customize what respondents see after they finish the form, including an optional next-step link. ··· 138 136 saveFormSettings: Save form settings 139 137 dragBlockAria: "Drag {label}" 140 138 prompt: Prompt 139 + headingField: Header 141 140 supportText: Support text 142 141 bodyCopy: Body copy 143 142 placeholder: Placeholder 143 + allowFloats: Allow decimal values 144 + minimumValue: Minimum value 145 + maximumValue: Maximum value 146 + regexValidation: Validate with regex 147 + regexPattern: Regex pattern 148 + regexHelp: Use a custom regex to constrain accepted answers for this text question. 149 + checkboxLabel: Checkbox label 144 150 choiceOptions: Choice options 145 151 addOption: Add option 146 152 option: "Option {number}" ··· 173 179 LONG_TEXT: Long text 174 180 SINGLE_CHOICE: Single choice 175 181 MULTIPLE_CHOICE: Multiple choice 182 + NUMBER: Number 183 + LINK: Link 184 + AGREEMENT: Agreement 185 + DATE: Date 176 186 publicRunner: 177 187 responseReceived: Response received 178 188 defaultCompletionTitle: Thanks for taking the time. ··· 180 190 step: "Step {current} of {total}" 181 191 context: Context 182 192 defaultTextTitle: Take a breath before the next step. 183 - question: Question 184 193 required: Required 185 194 chooseOne: Choose one option. 186 195 selectAll: Select all that apply. 187 196 answerRequired: Please answer before continuing. 188 197 singleChoiceRequired: Choose one option before continuing. 189 198 multiChoiceRequired: Choose at least one option before continuing. 199 + invalidNumber: Enter a valid number before continuing. 200 + wholeNumberRequired: Enter a whole number before continuing. 201 + numberAtLeast: "Enter a number greater than or equal to {min}." 202 + numberAtMost: "Enter a number less than or equal to {max}." 203 + invalidLink: Enter a valid link before continuing. 204 + invalidDate: Enter a valid date before continuing. 205 + openCalendar: Open calendar 206 + agreementRequired: You must agree before continuing. 207 + invalidTextFormat: Use the expected format before continuing. 208 + agree: Agree 209 + doNotAgree: Do not agree 190 210 submitError: Could not submit response. 191 211 back: Back 192 212 continue: Continue
+23 -3
locales/ru.yml
··· 101 101 noFormsDescription: Новые формы создаются как черновики, чтобы вы могли отредактировать их перед публикацией. 102 102 viewGrid: Сетка 103 103 viewTable: Таблица 104 - noDescription: Описание пока не добавлено. 105 104 shareUrl: Публичный URL 106 105 titleColumn: Название 107 106 statusColumn: Статус ··· 118 117 newBlock: Новый блок 119 118 settingsTitle: Настройки 120 119 titleField: Название 121 - descriptionField: Описание 122 120 shareSlug: Адрес публичного URL 123 121 afterSubmission: После отправки 124 122 afterSubmissionDescription: Настройте то, что увидят респонденты после завершения формы, включая необязательную ссылку на следующий шаг. ··· 138 136 saveFormSettings: Сохранить настройки формы 139 137 dragBlockAria: "Перетащить: {label}" 140 138 prompt: Вопрос 139 + headingField: Заголовок 141 140 supportText: Дополнительный текст 142 141 bodyCopy: Основной текст 143 142 placeholder: Плейсхолдер 143 + allowFloats: Разрешить дробные числа 144 + minimumValue: Минимальное значение 145 + maximumValue: Максимальное значение 146 + regexValidation: Проверять регулярным выражением 147 + regexPattern: Regex-шаблон 148 + regexHelp: Используйте собственный regex, чтобы ограничить формат ответа в этом текстовом вопросе. 149 + checkboxLabel: Текст галочки 144 150 choiceOptions: Варианты ответа 145 151 addOption: Добавить вариант 146 152 option: "Вариант {number}" ··· 173 179 LONG_TEXT: Длинный текст 174 180 SINGLE_CHOICE: Один вариант 175 181 MULTIPLE_CHOICE: Несколько вариантов 182 + NUMBER: Число 183 + LINK: Ссылка 184 + AGREEMENT: Согласие 185 + DATE: Дата 176 186 publicRunner: 177 187 responseReceived: Ответ получен 178 188 defaultCompletionTitle: Спасибо, что уделили время. ··· 180 190 step: "Шаг {current} из {total}" 181 191 context: Контекст 182 192 defaultTextTitle: Сделайте паузу перед следующим шагом. 183 - question: Вопрос 184 193 required: Обязательно 185 194 chooseOne: Выберите один вариант. 186 195 selectAll: Выберите все подходящие варианты. 187 196 answerRequired: Пожалуйста, ответьте перед продолжением. 188 197 singleChoiceRequired: Выберите один вариант перед продолжением. 189 198 multiChoiceRequired: Выберите хотя бы один вариант перед продолжением. 199 + invalidNumber: Введите корректное число перед продолжением. 200 + wholeNumberRequired: Введите целое число перед продолжением. 201 + numberAtLeast: "Введите число не меньше {min}." 202 + numberAtMost: "Введите число не больше {max}." 203 + invalidLink: Введите корректную ссылку перед продолжением. 204 + invalidDate: Введите корректную дату перед продолжением. 205 + openCalendar: Открыть календарь 206 + agreementRequired: Чтобы продолжить, нужно согласиться. 207 + invalidTextFormat: Используйте ожидаемый формат ответа перед продолжением. 208 + agree: Согласен 209 + doNotAgree: Не согласен 190 210 submitError: Не удалось отправить ответ. 191 211 back: Назад 192 212 continue: Далее
+43 -1
openspec/specs/anonymous-form-runner/spec.md
··· 30 30 - **THEN** the system includes that step in the visible progress through the form 31 31 32 32 ### Requirement: Required question blocks are validated before advancement 33 - The system SHALL prevent a respondent from advancing past a required question block until a valid answer is provided for that block type. 33 + The system SHALL prevent a respondent from advancing past a required question block until a valid answer is provided for that block type. For required agreement blocks, only an explicit agreed response SHALL satisfy the requirement. 34 34 35 35 #### Scenario: Respondent skips a required short text question 36 36 - **WHEN** a respondent attempts to continue from a required text question without an answer ··· 39 39 #### Scenario: Respondent skips a required multiple choice question 40 40 - **WHEN** a respondent attempts to continue from a required multiple choice block without selecting any options 41 41 - **THEN** the system blocks advancement and indicates that an answer is required 42 + 43 + #### Scenario: Respondent does not agree to a required agreement block 44 + - **WHEN** a respondent attempts to continue from a required agreement block without selecting agreed 45 + - **THEN** the system blocks advancement and indicates that agreement is required 46 + 47 + ### Requirement: Structured question answers are validated by block type 48 + The system SHALL validate number, link, agreement, and date answers according to the active block kind before allowing a respondent to advance or submit the form. When a text-answer block is configured with custom regex validation, the system SHALL also validate the respondent's text answer against that regex pattern. 49 + 50 + #### Scenario: Respondent enters an invalid number 51 + - **WHEN** a respondent provides a non-numeric value for a number block 52 + - **THEN** the system blocks advancement and indicates that a valid number is required 53 + 54 + #### Scenario: Respondent enters a float when floats are disallowed 55 + - **WHEN** a respondent provides a decimal value for a number block configured to allow only whole numbers 56 + - **THEN** the system blocks advancement and indicates that a whole number is required 57 + 58 + #### Scenario: Respondent enters a value outside the allowed numeric range 59 + - **WHEN** a respondent provides a number below the configured minimum or above the configured maximum for a number block 60 + - **THEN** the system blocks advancement and indicates that the answer is outside the allowed range 61 + 62 + #### Scenario: Respondent enters an invalid link 63 + - **WHEN** a respondent provides a malformed link value for a link block 64 + - **THEN** the system blocks advancement and indicates that a valid link is required 65 + 66 + #### Scenario: Respondent enters an invalid date 67 + - **WHEN** a respondent provides an invalid or incomplete date value for a date block 68 + - **THEN** the system blocks advancement and indicates that a valid date is required 69 + 70 + #### Scenario: Respondent answers an optional agreement block 71 + - **WHEN** a respondent selects agreed or not agreed for an optional agreement block 72 + - **THEN** the system treats the explicit selection as the saved answer for that block 73 + 74 + #### Scenario: Respondent enters text that does not match a configured regex 75 + - **WHEN** a respondent provides a text answer for a text-answer block with custom regex validation and the answer does not match the saved pattern 76 + - **THEN** the system blocks advancement and indicates that the answer format is invalid 77 + 78 + ### Requirement: Form submission preserves structured answers for supported block kinds 79 + The system SHALL store submitted answers for supported number, link, agreement, and date blocks in the anonymous response data so creator-facing review flows can display them. 80 + 81 + #### Scenario: Respondent submits a form with structured answers 82 + - **WHEN** a respondent submits a published form containing number, link, agreement, or date blocks 83 + - **THEN** the system stores those answers with the submission for later review 42 84 43 85 ### Requirement: Form submission stores anonymous responses 44 86 The system SHALL allow a respondent to submit a completed published form anonymously, SHALL store only the form response data needed for review, without using creator login credentials as part of submission identity, and SHALL show a completion state based on the form's configured completion content.
+18 -2
openspec/specs/conversational-form-builder/spec.md
··· 16 16 - **THEN** the system saves the updated title for that form 17 17 18 18 ### Requirement: Creator can manage an ordered list of supported blocks 19 - The system SHALL allow an authenticated creator to add, select, edit, reorder, and remove blocks in a form located in an accessible workspace using only the supported v0 block types: text, short text, long text, single choice, and multiple choice. 19 + The system SHALL allow an authenticated creator to add, select, edit, reorder, and remove blocks in a form located in an accessible workspace using the supported block types: text, short text, long text, single choice, multiple choice, number, link, agreement, and date. 20 20 21 21 #### Scenario: Creator adds a block 22 22 - **WHEN** an authenticated creator adds a supported block type to a form in an accessible workspace ··· 42 42 - **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column 43 43 44 44 ### Requirement: Question blocks support required state and type-specific configuration 45 - The system SHALL allow question blocks to be marked as required and SHALL support type-specific configuration for placeholders and choice options. Text blocks SHALL NOT be answerable and SHALL NOT expose a required setting. 45 + The system SHALL allow question blocks to be marked as required and SHALL support type-specific configuration for placeholders, choice options, optional regex validation for text-answer blocks, and numeric constraints for number blocks. Number, link, agreement, and date blocks SHALL be treated as answerable question blocks with validation appropriate to their kind. Text blocks SHALL NOT be answerable and SHALL NOT expose a required setting. 46 46 47 47 #### Scenario: Creator marks a short text question required 48 48 - **WHEN** an authenticated creator enables required state for a question block ··· 55 55 #### Scenario: Creator edits a text block 56 56 - **WHEN** an authenticated creator edits a text block 57 57 - **THEN** the system allows content updates without exposing answer validation settings 58 + 59 + #### Scenario: Creator adds an agreement block 60 + - **WHEN** an authenticated creator adds an agreement block 61 + - **THEN** the system saves it as an answerable block that can be marked required 62 + 63 + #### Scenario: Creator adds a date block 64 + - **WHEN** an authenticated creator adds a date block 65 + - **THEN** the system saves it as an answerable block that accepts date responses 66 + 67 + #### Scenario: Creator configures numeric constraints on a number question 68 + - **WHEN** an authenticated creator configures a number block to disallow floats or sets minimum and maximum values 69 + - **THEN** the system saves those numeric validation settings as part of the block configuration 70 + 71 + #### Scenario: Creator configures regex validation on a text question 72 + - **WHEN** an authenticated creator enables custom regex validation for a text-answer block and provides a pattern 73 + - **THEN** the system saves that validation pattern as part of the block configuration 58 74 59 75 ### Requirement: Creator can publish and unpublish forms 60 76 The system SHALL allow an authenticated creator to publish or unpublish a form in an accessible workspace and SHALL expose a public shareable route only for published forms.
+3
package.json
··· 33 33 "@dnd-kit/sortable": "^10.0.0", 34 34 "@dnd-kit/utilities": "^3.2.2", 35 35 "@prisma/client": "6", 36 + "@radix-ui/react-popover": "^1.1.15", 36 37 "@radix-ui/react-select": "^2.2.6", 37 38 "class-variance-authority": "^0.7.1", 38 39 "clsx": "^2.1.1", 40 + "date-fns": "^4.1.0", 39 41 "framer-motion": "^12.38.0", 40 42 "lucide-react": "^1.7.0", 41 43 "next": "^16.2.2", 42 44 "next-auth": "^4.24.13", 43 45 "react": "^19.2.4", 46 + "react-day-picker": "^9.14.0", 44 47 "react-dom": "^19.2.4", 45 48 "tailwind-merge": "^3.5.0", 46 49 "xlsx": "^0.18.5",
+4
prisma/migrations/20260410140000_add_extended_block_types/migration.sql
··· 1 + ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'NUMBER'; 2 + ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'LINK'; 3 + ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'AGREEMENT'; 4 + ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'DATE';
+3
prisma/migrations/20260410143000_localize_completion_defaults/migration.sql
··· 1 + ALTER TABLE "Form" 2 + ALTER COLUMN "completionTitle" SET DEFAULT '', 3 + ALTER COLUMN "completionMessage" SET DEFAULT '';
+6 -2
prisma/schema.prisma
··· 18 18 LONG_TEXT 19 19 SINGLE_CHOICE 20 20 MULTIPLE_CHOICE 21 + NUMBER 22 + LINK 23 + AGREEMENT 24 + DATE 21 25 } 22 26 23 27 enum OrganizationMemberRole { ··· 88 92 organizationId String? 89 93 title String @default("Untitled form") 90 94 description String @default("") 91 - completionTitle String @default("Thanks for taking the time.") 92 - completionMessage String @default("Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.") 95 + completionTitle String @default("") 96 + completionMessage String @default("") 93 97 completionLinkLabel String? 94 98 completionLinkUrl String? 95 99 slug String @unique