···11-import { FormBlockType, type FormBlock } from "@prisma/client";
11+import type { FormBlock, FormBlockType as PrismaFormBlockType } from "@prisma/client";
22import { z } from "zod";
3344import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n";
5566-export const blockTypeLabels: Record<FormBlockType, string> = {
66+const FORM_BLOCK_TYPES = {
77+ TEXT: "TEXT",
88+ SHORT_TEXT: "SHORT_TEXT",
99+ LONG_TEXT: "LONG_TEXT",
1010+ SINGLE_CHOICE: "SINGLE_CHOICE",
1111+ MULTIPLE_CHOICE: "MULTIPLE_CHOICE",
1212+ NUMBER: "NUMBER",
1313+ LINK: "LINK",
1414+ AGREEMENT: "AGREEMENT",
1515+ DATE: "DATE",
1616+} as const satisfies Record<PrismaFormBlockType, PrismaFormBlockType>;
1717+1818+export const blockTypeValues = [
1919+ FORM_BLOCK_TYPES.TEXT,
2020+ FORM_BLOCK_TYPES.SHORT_TEXT,
2121+ FORM_BLOCK_TYPES.LONG_TEXT,
2222+ FORM_BLOCK_TYPES.SINGLE_CHOICE,
2323+ FORM_BLOCK_TYPES.MULTIPLE_CHOICE,
2424+ FORM_BLOCK_TYPES.NUMBER,
2525+ FORM_BLOCK_TYPES.LINK,
2626+ FORM_BLOCK_TYPES.AGREEMENT,
2727+ FORM_BLOCK_TYPES.DATE,
2828+] as const;
2929+3030+export const blockTypeSchema = z.enum(blockTypeValues);
3131+3232+export const blockTypeLabels: Record<PrismaFormBlockType, string> = {
733 TEXT: "Text block",
834 SHORT_TEXT: "Short text",
935 LONG_TEXT: "Long text",
1036 SINGLE_CHOICE: "Single choice",
1137 MULTIPLE_CHOICE: "Multiple choice",
3838+ NUMBER: "Number",
3939+ LINK: "Link",
4040+ AGREEMENT: "Agreement",
4141+ DATE: "Date",
1242};
13431414-export const blockTypeTranslationKeys: Record<FormBlockType, string> = {
4444+export const blockTypeTranslationKeys: Record<PrismaFormBlockType, string> = {
1545 TEXT: "blocks.types.TEXT",
1646 SHORT_TEXT: "blocks.types.SHORT_TEXT",
1747 LONG_TEXT: "blocks.types.LONG_TEXT",
1848 SINGLE_CHOICE: "blocks.types.SINGLE_CHOICE",
1949 MULTIPLE_CHOICE: "blocks.types.MULTIPLE_CHOICE",
5050+ NUMBER: "blocks.types.NUMBER",
5151+ LINK: "blocks.types.LINK",
5252+ AGREEMENT: "blocks.types.AGREEMENT",
5353+ DATE: "blocks.types.DATE",
2054};
21552222-const defaultBlockCopyByLocale: Record<AppLocale, { textBody: string; shortPlaceholder: string; longPlaceholder: string; options: [string, string] }> = {
5656+const defaultBlockCopyByLocale: Record<
5757+ AppLocale,
5858+ {
5959+ textBody: string;
6060+ shortPlaceholder: string;
6161+ longPlaceholder: string;
6262+ numberPlaceholder: string;
6363+ linkPlaceholder: string;
6464+ agreementLabel: string;
6565+ options: [string, string];
6666+ }
6767+> = {
2368 en: {
2469 textBody: "Introduce the next part of your form.",
2570 shortPlaceholder: "Type your answer here…",
2671 longPlaceholder: "Tell us a little more…",
7272+ numberPlaceholder: "42",
7373+ linkPlaceholder: "https://example.com",
7474+ agreementLabel: "I agree",
2775 options: ["Option 1", "Option 2"],
2876 },
2977 ru: {
3078 textBody: "Подготовьте человека к следующей части формы.",
3179 shortPlaceholder: "Введите ответ…",
3280 longPlaceholder: "Расскажите немного подробнее…",
8181+ numberPlaceholder: "42",
8282+ linkPlaceholder: "https://example.com",
8383+ agreementLabel: "Я согласен",
3384 options: ["Вариант 1", "Вариант 2"],
3485 },
3586};
···3889 return defaultBlockCopyByLocale[locale] ?? defaultBlockCopyByLocale[DEFAULT_LOCALE];
3990}
40919292+function normalizeRegexPattern(value: unknown) {
9393+ if (typeof value !== "string") {
9494+ return null;
9595+ }
9696+9797+ const pattern = value.trim();
9898+ return pattern || null;
9999+}
100100+101101+const regexPatternSchema = z
102102+ .unknown()
103103+ .transform(normalizeRegexPattern)
104104+ .superRefine((value, context) => {
105105+ if (!value) {
106106+ return;
107107+ }
108108+109109+ try {
110110+ new RegExp(value);
111111+ } catch {
112112+ context.addIssue({
113113+ code: z.ZodIssueCode.custom,
114114+ message: "Use a valid regex pattern.",
115115+ });
116116+ }
117117+ });
118118+119119+const numericLimitSchema = z
120120+ .unknown()
121121+ .transform((value) => {
122122+ if (value === null || typeof value === "undefined") {
123123+ return null;
124124+ }
125125+126126+ if (typeof value === "number") {
127127+ return Number.isFinite(value) ? value : Number.NaN;
128128+ }
129129+130130+ if (typeof value === "string") {
131131+ const trimmed = value.trim();
132132+133133+ if (!trimmed) {
134134+ return null;
135135+ }
136136+137137+ const nextValue = Number(trimmed);
138138+ return Number.isFinite(nextValue) ? nextValue : Number.NaN;
139139+ }
140140+141141+ return Number.NaN;
142142+ })
143143+ .refine((value) => value === null || Number.isFinite(value), {
144144+ message: "Use a valid number.",
145145+ });
146146+41147const textConfigSchema = z.object({
42148 body: z.string().max(2400).default(defaultBlockCopyByLocale.en.textBody),
43149});
4415045151const shortTextConfigSchema = z.object({
46152 placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.shortPlaceholder),
153153+ validationRegex: regexPatternSchema.default(null),
47154});
4815549156const longTextConfigSchema = z.object({
50157 placeholder: z.string().max(240).default(defaultBlockCopyByLocale.en.longPlaceholder),
158158+ validationRegex: regexPatternSchema.default(null),
51159});
52160161161+const numberConfigSchema = z
162162+ .object({
163163+ placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.numberPlaceholder),
164164+ allowFloat: z.boolean().default(false),
165165+ min: numericLimitSchema.default(null),
166166+ max: numericLimitSchema.default(null),
167167+ })
168168+ .superRefine((value, context) => {
169169+ if (!value.allowFloat) {
170170+ if (value.min !== null && !Number.isInteger(value.min)) {
171171+ context.addIssue({
172172+ code: z.ZodIssueCode.custom,
173173+ path: ["min"],
174174+ message: "Use a whole number when floats are disabled.",
175175+ });
176176+ }
177177+178178+ if (value.max !== null && !Number.isInteger(value.max)) {
179179+ context.addIssue({
180180+ code: z.ZodIssueCode.custom,
181181+ path: ["max"],
182182+ message: "Use a whole number when floats are disabled.",
183183+ });
184184+ }
185185+ }
186186+187187+ if (value.min !== null && value.max !== null && value.min > value.max) {
188188+ context.addIssue({
189189+ code: z.ZodIssueCode.custom,
190190+ path: ["max"],
191191+ message: "Maximum must be greater than or equal to minimum.",
192192+ });
193193+ }
194194+ });
195195+196196+const linkConfigSchema = z.object({
197197+ placeholder: z.string().max(2048).default(defaultBlockCopyByLocale.en.linkPlaceholder),
198198+});
199199+200200+const agreementConfigSchema = z.object({
201201+ label: z.string().trim().max(160).default(defaultBlockCopyByLocale.en.agreementLabel),
202202+});
203203+204204+const dateConfigSchema = z.object({});
205205+53206const optionListSchema = z.object({
54207 options: z.array(z.string().min(1).max(120)).min(2).max(10).default(defaultBlockCopyByLocale.en.options),
55208});
56209210210+export const AGREEMENT_ANSWER_VALUES = {
211211+ AGREED: "agreed",
212212+ NOT_AGREED: "not_agreed",
213213+} as const;
214214+215215+export type AgreementAnswerValue = (typeof AGREEMENT_ANSWER_VALUES)[keyof typeof AGREEMENT_ANSWER_VALUES];
57216export type TextBlockConfig = z.infer<typeof textConfigSchema>;
58217export type ShortTextBlockConfig = z.infer<typeof shortTextConfigSchema>;
59218export type LongTextBlockConfig = z.infer<typeof longTextConfigSchema>;
219219+export type NumberBlockConfig = z.infer<typeof numberConfigSchema>;
220220+export type LinkBlockConfig = z.infer<typeof linkConfigSchema>;
221221+export type AgreementBlockConfig = z.infer<typeof agreementConfigSchema>;
222222+export type DateBlockConfig = z.infer<typeof dateConfigSchema>;
60223export type ChoiceBlockConfig = z.infer<typeof optionListSchema>;
224224+export type TextAnswerBlockConfig = ShortTextBlockConfig | LongTextBlockConfig;
61225export type BlockConfig =
62226 | TextBlockConfig
63227 | ShortTextBlockConfig
64228 | LongTextBlockConfig
229229+ | NumberBlockConfig
230230+ | LinkBlockConfig
231231+ | AgreementBlockConfig
232232+ | DateBlockConfig
65233 | ChoiceBlockConfig;
6623467235export type SerializedBlock = Omit<FormBlock, "config"> & {
68236 config: BlockConfig;
69237};
702387171-export function isQuestionBlock(type: FormBlockType) {
7272- return type !== FormBlockType.TEXT;
239239+export function isQuestionBlock(type: PrismaFormBlockType) {
240240+ return type !== FORM_BLOCK_TYPES.TEXT;
241241+}
242242+243243+export function isTextAnswerBlock(type: PrismaFormBlockType) {
244244+ return type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT;
245245+}
246246+247247+export function isChoiceBlock(type: PrismaFormBlockType) {
248248+ return type === FORM_BLOCK_TYPES.SINGLE_CHOICE || type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE;
249249+}
250250+251251+export function getTextValidationPattern(config: TextAnswerBlockConfig) {
252252+ return config.validationRegex ?? null;
253253+}
254254+255255+export function isAgreementAnswerValue(value: string): value is AgreementAnswerValue {
256256+ return value === AGREEMENT_ANSWER_VALUES.AGREED || value === AGREEMENT_ANSWER_VALUES.NOT_AGREED;
257257+}
258258+259259+export function parseNumericAnswer(value: string) {
260260+ const trimmed = value.trim();
261261+262262+ if (!trimmed) {
263263+ return null;
264264+ }
265265+266266+ const parsed = Number(trimmed);
267267+ return Number.isFinite(parsed) ? parsed : null;
73268}
742697575-export function getDefaultBlockConfig(type: FormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig {
270270+export function isValidLinkAnswer(value: string) {
271271+ try {
272272+ const parsed = new URL(value);
273273+ return parsed.protocol === "http:" || parsed.protocol === "https:";
274274+ } catch {
275275+ return false;
276276+ }
277277+}
278278+279279+export function isValidDateAnswer(value: string) {
280280+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
281281+ return false;
282282+ }
283283+284284+ const date = new Date(`${value}T00:00:00.000Z`);
285285+ return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
286286+}
287287+288288+export function getDefaultBlockConfig(type: PrismaFormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig {
76289 const copy = getDefaultBlockCopy(locale);
7729078291 switch (type) {
7979- case FormBlockType.TEXT:
292292+ case FORM_BLOCK_TYPES.TEXT:
80293 return textConfigSchema.parse({ body: copy.textBody });
8181- case FormBlockType.SHORT_TEXT:
294294+ case FORM_BLOCK_TYPES.SHORT_TEXT:
82295 return shortTextConfigSchema.parse({ placeholder: copy.shortPlaceholder });
8383- case FormBlockType.LONG_TEXT:
296296+ case FORM_BLOCK_TYPES.LONG_TEXT:
84297 return longTextConfigSchema.parse({ placeholder: copy.longPlaceholder });
8585- case FormBlockType.SINGLE_CHOICE:
8686- case FormBlockType.MULTIPLE_CHOICE:
298298+ case FORM_BLOCK_TYPES.SINGLE_CHOICE:
299299+ case FORM_BLOCK_TYPES.MULTIPLE_CHOICE:
87300 return optionListSchema.parse({ options: copy.options });
301301+ case FORM_BLOCK_TYPES.NUMBER:
302302+ return numberConfigSchema.parse({ placeholder: copy.numberPlaceholder });
303303+ case FORM_BLOCK_TYPES.LINK:
304304+ return linkConfigSchema.parse({ placeholder: copy.linkPlaceholder });
305305+ case FORM_BLOCK_TYPES.AGREEMENT:
306306+ return agreementConfigSchema.parse({ label: copy.agreementLabel });
307307+ case FORM_BLOCK_TYPES.DATE:
308308+ return dateConfigSchema.parse({});
88309 default:
89310 return textConfigSchema.parse({ body: copy.textBody });
90311 }
91312}
923139393-export function parseBlockConfig(type: FormBlockType, value: unknown): BlockConfig {
314314+export function parseBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig {
94315 switch (type) {
9595- case FormBlockType.TEXT:
316316+ case FORM_BLOCK_TYPES.TEXT:
96317 return textConfigSchema.parse(value ?? {});
9797- case FormBlockType.SHORT_TEXT:
318318+ case FORM_BLOCK_TYPES.SHORT_TEXT:
98319 return shortTextConfigSchema.parse(value ?? {});
9999- case FormBlockType.LONG_TEXT:
320320+ case FORM_BLOCK_TYPES.LONG_TEXT:
100321 return longTextConfigSchema.parse(value ?? {});
101101- case FormBlockType.SINGLE_CHOICE:
102102- case FormBlockType.MULTIPLE_CHOICE:
322322+ case FORM_BLOCK_TYPES.SINGLE_CHOICE:
323323+ case FORM_BLOCK_TYPES.MULTIPLE_CHOICE:
103324 return optionListSchema.parse(value ?? {});
325325+ case FORM_BLOCK_TYPES.NUMBER:
326326+ return numberConfigSchema.parse(value ?? {});
327327+ case FORM_BLOCK_TYPES.LINK:
328328+ return linkConfigSchema.parse(value ?? {});
329329+ case FORM_BLOCK_TYPES.AGREEMENT:
330330+ return agreementConfigSchema.parse(value ?? {});
331331+ case FORM_BLOCK_TYPES.DATE:
332332+ return dateConfigSchema.parse(value ?? {});
104333 default:
105334 return textConfigSchema.parse(value ?? {});
106335 }
···120349 return block.title;
121350 }
122351123123- if (block.type === FormBlockType.TEXT && "body" in config) {
352352+ if (block.type === FORM_BLOCK_TYPES.TEXT && "body" in config) {
124353 return config.body;
125354 }
126355127127- if ("placeholder" in config && config.placeholder.trim()) {
356356+ if (
357357+ (block.type === FORM_BLOCK_TYPES.SHORT_TEXT ||
358358+ block.type === FORM_BLOCK_TYPES.LONG_TEXT ||
359359+ block.type === FORM_BLOCK_TYPES.NUMBER ||
360360+ block.type === FORM_BLOCK_TYPES.LINK) &&
361361+ "placeholder" in config &&
362362+ config.placeholder.trim()
363363+ ) {
128364 return config.placeholder;
129365 }
130366131367 if ("options" in config) {
132368 return config.options.join(" • ");
369369+ }
370370+371371+ if (block.type === FORM_BLOCK_TYPES.AGREEMENT && "label" in config && config.label.trim()) {
372372+ return config.label;
133373 }
134374135375 return block.description || fallbackLabel || blockTypeLabels[block.type];
+32
lib/form-defaults.ts
···11+import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n";
22+33+export const legacyCompletionDefaults = {
44+ title: "Thanks for taking the time.",
55+ message:
66+ "Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.",
77+} as const;
88+99+export const completionDefaultsByLocale: Record<AppLocale, { title: string; message: string }> = {
1010+ en: {
1111+ title: "Thanks for taking the time.",
1212+ message:
1313+ "Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.",
1414+ },
1515+ ru: {
1616+ title: "Спасибо, что уделили время.",
1717+ message:
1818+ "Ваш ответ был отправлен анонимно. Создатель формы может просмотреть ответы, но они не связаны с вашим аккаунтом или входом в систему.",
1919+ },
2020+};
2121+2222+export function getLocalizedCompletionDefaults(locale: AppLocale = DEFAULT_LOCALE) {
2323+ return completionDefaultsByLocale[locale] ?? completionDefaultsByLocale[DEFAULT_LOCALE];
2424+}
2525+2626+export function isLegacyDefaultCompletionTitle(value: string | null | undefined) {
2727+ return (value ?? "").trim() === legacyCompletionDefaults.title;
2828+}
2929+3030+export function isLegacyDefaultCompletionMessage(value: string | null | undefined) {
3131+ return (value ?? "").trim() === legacyCompletionDefaults.message;
3232+}
+167-25
lib/forms.ts
···11import {
22- FormBlockType,
32 FormStatus,
43 type Form,
54 type FormBlock,
55+ type FormBlockType as PrismaFormBlockType,
66 type Prisma,
77} from "@prisma/client";
88import { z } from "zod";
991010import {
1111+ AGREEMENT_ANSWER_VALUES,
1212+ blockTypeSchema,
1113 getDefaultBlockConfig,
1414+ isAgreementAnswerValue,
1515+ isChoiceBlock,
1216 isQuestionBlock,
1717+ isTextAnswerBlock,
1818+ isValidDateAnswer,
1919+ isValidLinkAnswer,
1320 parseBlockConfig,
2121+ parseNumericAnswer,
1422 serializeBlock,
1523 type BlockConfig,
2424+ type ChoiceBlockConfig,
2525+ type LinkBlockConfig,
2626+ type NumberBlockConfig,
1627 type SerializedBlock,
2828+ type TextAnswerBlockConfig,
1729} from "@/lib/blocks";
1830import { db } from "@/lib/db";
1931import { AppError } from "@/lib/errors";
3232+import { getLocalizedCompletionDefaults } from "@/lib/form-defaults";
2033import type {
2134 ActiveWorkspace,
2235 BuilderForm,
···3851import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n";
3952import { assertOrganizationMember, workspaceAccessWhere, workspaceFilterWhere } from "@/lib/workspaces";
40535454+const FORM_BLOCK_TYPES = {
5555+ TEXT: "TEXT",
5656+ SHORT_TEXT: "SHORT_TEXT",
5757+ LONG_TEXT: "LONG_TEXT",
5858+ SINGLE_CHOICE: "SINGLE_CHOICE",
5959+ MULTIPLE_CHOICE: "MULTIPLE_CHOICE",
6060+ NUMBER: "NUMBER",
6161+ LINK: "LINK",
6262+ AGREEMENT: "AGREEMENT",
6363+ DATE: "DATE",
6464+} as const satisfies Record<PrismaFormBlockType, PrismaFormBlockType>;
6565+4166const builderFormInclude = {
4267 organization: {
4368 select: {
···8310884109const snapshotBlockSchema = z.object({
85110 id: z.string(),
8686- type: z.nativeEnum(FormBlockType),
111111+ type: blockTypeSchema,
87112 title: z.string(),
88113 description: z.string(),
89114 required: z.boolean(),
···148173149174const defaultFormCopyByLocale: Record<AppLocale, {
150175 untitledForm: string;
176176+ completionTitle: string;
177177+ completionMessage: string;
151178 initialBlocks: Prisma.FormBlockCreateWithoutFormInput[];
152152- newBlockTitles: Record<FormBlockType, string>;
179179+ newBlockTitles: Record<PrismaFormBlockType, string>;
153180}> = {
154181 en: {
155182 untitledForm: "Untitled form",
183183+ completionTitle: getLocalizedCompletionDefaults("en").title,
184184+ completionMessage: getLocalizedCompletionDefaults("en").message,
156185 initialBlocks: [
157186 {
158158- type: FormBlockType.TEXT,
187187+ type: FORM_BLOCK_TYPES.TEXT,
159188 title: "Welcome",
160189 description: "Set the scene before people start answering.",
161190 position: 0,
162191 required: false,
163163- config: getDefaultBlockConfig(FormBlockType.TEXT, "en"),
192192+ config: getDefaultBlockConfig(FORM_BLOCK_TYPES.TEXT, "en"),
164193 },
165194 {
166166- type: FormBlockType.SHORT_TEXT,
195195+ type: FORM_BLOCK_TYPES.SHORT_TEXT,
167196 title: "What should we call you?",
168197 description: "A simple first question keeps the flow moving.",
169198 position: 1,
170199 required: true,
171171- config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT, "en"),
200200+ config: getDefaultBlockConfig(FORM_BLOCK_TYPES.SHORT_TEXT, "en"),
172201 },
173202 ],
174203 newBlockTitles: {
···177206 LONG_TEXT: "Long text question",
178207 SINGLE_CHOICE: "Single choice question",
179208 MULTIPLE_CHOICE: "Multiple choice question",
209209+ NUMBER: "Number question",
210210+ LINK: "Link question",
211211+ AGREEMENT: "Agreement question",
212212+ DATE: "Date question",
180213 },
181214 },
182215 ru: {
183216 untitledForm: "Форма без названия",
217217+ completionTitle: getLocalizedCompletionDefaults("ru").title,
218218+ completionMessage: getLocalizedCompletionDefaults("ru").message,
184219 initialBlocks: [
185220 {
186186- type: FormBlockType.TEXT,
221221+ type: FORM_BLOCK_TYPES.TEXT,
187222 title: "Добро пожаловать",
188223 description: "Задайте тон перед тем, как люди начнут отвечать.",
189224 position: 0,
190225 required: false,
191191- config: getDefaultBlockConfig(FormBlockType.TEXT, "ru"),
226226+ config: getDefaultBlockConfig(FORM_BLOCK_TYPES.TEXT, "ru"),
192227 },
193228 {
194194- type: FormBlockType.SHORT_TEXT,
229229+ type: FORM_BLOCK_TYPES.SHORT_TEXT,
195230 title: "Как к вам обращаться?",
196231 description: "Простой первый вопрос помогает сохранить ритм.",
197232 position: 1,
198233 required: true,
199199- config: getDefaultBlockConfig(FormBlockType.SHORT_TEXT, "ru"),
234234+ config: getDefaultBlockConfig(FORM_BLOCK_TYPES.SHORT_TEXT, "ru"),
200235 },
201236 ],
202237 newBlockTitles: {
···205240 LONG_TEXT: "Вопрос с длинным ответом",
206241 SINGLE_CHOICE: "Вопрос с одним выбором",
207242 MULTIPLE_CHOICE: "Вопрос с несколькими вариантами",
243243+ NUMBER: "Вопрос с числом",
244244+ LINK: "Вопрос со ссылкой",
245245+ AGREEMENT: "Вопрос с согласием",
246246+ DATE: "Вопрос с датой",
208247 },
209248 },
210249};
···273312 .slice(0, 10);
274313}
275314276276-function normalizeBlockConfig(type: FormBlockType, value: unknown): BlockConfig {
277277- if (type === FormBlockType.SINGLE_CHOICE || type === FormBlockType.MULTIPLE_CHOICE) {
315315+function normalizeBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig {
316316+ if (isChoiceBlock(type)) {
278317 const rawOptions =
279318 typeof value === "object" && value && "options" in value && Array.isArray(value.options)
280319 ? value.options.filter((option): option is string => typeof option === "string")
281320 : [];
282321 const options = sanitizeOptions(rawOptions);
283322284284- return {
323323+ return parseBlockConfig(type, {
285324 options: options.length >= 2 ? options : ["Option 1", "Option 2"],
286286- };
325325+ });
287326 }
288327289328 return parseBlockConfig(type, value);
290329}
291330331331+function getQuestionLabel(block: SerializedBlock) {
332332+ return block.title || "this question";
333333+}
334334+292335function normalizeAnswer(block: SerializedBlock, rawValue: unknown): string | string[] | undefined {
293336 const config = block.config;
337337+ const questionLabel = getQuestionLabel(block);
294338295339 if (!isQuestionBlock(block.type)) {
296340 return undefined;
297341 }
298342299299- if (block.type === FormBlockType.SHORT_TEXT || block.type === FormBlockType.LONG_TEXT) {
343343+ if (isTextAnswerBlock(block.type)) {
300344 const value = typeof rawValue === "string" ? rawValue.trim() : "";
345345+ const validationRegex = (config as TextAnswerBlockConfig).validationRegex;
301346302347 if (!value) {
303348 if (block.required) {
304304- throw new AppError(`Please answer “${block.title || "this question"}”.`, 422);
349349+ throw new AppError(`Please answer “${questionLabel}”.`, 422);
305350 }
306351307352 return undefined;
308353 }
309354355355+ if (validationRegex && !new RegExp(validationRegex).test(value)) {
356356+ throw new AppError(`Please use a valid format for “${questionLabel}”.`, 422);
357357+ }
358358+310359 return value;
311360 }
312361313313- if (block.type === FormBlockType.SINGLE_CHOICE) {
362362+ if (block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE) {
314363 const value = typeof rawValue === "string" ? rawValue.trim() : "";
315315- const options = "options" in config ? config.options : [];
364364+ const options = (config as ChoiceBlockConfig).options;
316365317366 if (!value) {
318367 if (block.required) {
319319- throw new AppError(`Please choose an option for “${block.title || "this question"}”.`, 422);
368368+ throw new AppError(`Please choose an option for “${questionLabel}”.`, 422);
320369 }
321370322371 return undefined;
323372 }
324373325374 if (!options.includes(value)) {
326326- throw new AppError(`Invalid option submitted for “${block.title || "this question"}”.`, 422);
375375+ throw new AppError(`Invalid option submitted for “${questionLabel}”.`, 422);
327376 }
328377329378 return value;
330379 }
331380332332- if (block.type === FormBlockType.MULTIPLE_CHOICE) {
381381+ if (block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE) {
333382 const values = Array.isArray(rawValue)
334383 ? rawValue.filter((value): value is string => typeof value === "string")
335384 : [];
336336- const options = "options" in config ? config.options : [];
385385+ const options = (config as ChoiceBlockConfig).options;
337386 const uniqueValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))];
338387339388 if (!uniqueValues.length) {
340389 if (block.required) {
341341- throw new AppError(`Please choose at least one option for “${block.title || "this question"}”.`, 422);
390390+ throw new AppError(`Please choose at least one option for “${questionLabel}”.`, 422);
342391 }
343392344393 return undefined;
345394 }
346395347396 if (!uniqueValues.every((value) => options.includes(value))) {
348348- throw new AppError(`Invalid option submitted for “${block.title || "this question"}”.`, 422);
397397+ throw new AppError(`Invalid option submitted for “${questionLabel}”.`, 422);
349398 }
350399351400 return uniqueValues;
352401 }
353402403403+ if (block.type === FORM_BLOCK_TYPES.NUMBER) {
404404+ const value = typeof rawValue === "string" ? rawValue.trim() : "";
405405+ const numberConfig = config as NumberBlockConfig;
406406+407407+ if (!value) {
408408+ if (block.required) {
409409+ throw new AppError(`Please enter a number for “${questionLabel}”.`, 422);
410410+ }
411411+412412+ return undefined;
413413+ }
414414+415415+ const numericValue = parseNumericAnswer(value);
416416+417417+ if (numericValue === null) {
418418+ throw new AppError(`Please enter a valid number for “${questionLabel}”.`, 422);
419419+ }
420420+421421+ if (!numberConfig.allowFloat && !Number.isInteger(numericValue)) {
422422+ throw new AppError(`Please enter a whole number for “${questionLabel}”.`, 422);
423423+ }
424424+425425+ if (numberConfig.min !== null && numericValue < numberConfig.min) {
426426+ throw new AppError(`Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`, 422);
427427+ }
428428+429429+ if (numberConfig.max !== null && numericValue > numberConfig.max) {
430430+ throw new AppError(`Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`, 422);
431431+ }
432432+433433+ return String(numericValue);
434434+ }
435435+436436+ if (block.type === FORM_BLOCK_TYPES.LINK) {
437437+ const value = typeof rawValue === "string" ? rawValue.trim() : "";
438438+439439+ if (!value) {
440440+ if (block.required) {
441441+ throw new AppError(`Please enter a link for “${questionLabel}”.`, 422);
442442+ }
443443+444444+ return undefined;
445445+ }
446446+447447+ if (!isValidLinkAnswer(value)) {
448448+ throw new AppError(`Please enter a valid link for “${questionLabel}”.`, 422);
449449+ }
450450+451451+ return new URL(value).toString();
452452+ }
453453+454454+ if (block.type === FORM_BLOCK_TYPES.DATE) {
455455+ const value = typeof rawValue === "string" ? rawValue.trim() : "";
456456+457457+ if (!value) {
458458+ if (block.required) {
459459+ throw new AppError(`Please enter a date for “${questionLabel}”.`, 422);
460460+ }
461461+462462+ return undefined;
463463+ }
464464+465465+ if (!isValidDateAnswer(value)) {
466466+ throw new AppError(`Please enter a valid date for “${questionLabel}”.`, 422);
467467+ }
468468+469469+ return value;
470470+ }
471471+472472+ if (block.type === FORM_BLOCK_TYPES.AGREEMENT) {
473473+ const value = typeof rawValue === "string" ? rawValue.trim() : "";
474474+475475+ if (!value) {
476476+ if (block.required) {
477477+ throw new AppError(`Agreement is required for “${questionLabel}”.`, 422);
478478+ }
479479+480480+ return undefined;
481481+ }
482482+483483+ if (!isAgreementAnswerValue(value)) {
484484+ throw new AppError(`Invalid agreement value submitted for “${questionLabel}”.`, 422);
485485+ }
486486+487487+ if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) {
488488+ throw new AppError(`Agreement is required for “${questionLabel}”.`, 422);
489489+ }
490490+491491+ return value;
492492+ }
493493+354494 return undefined;
355495}
356496···429569 organizationId: workspace.kind === "organization" ? workspace.organizationId : null,
430570 title: copy.untitledForm,
431571 description: "",
572572+ completionTitle: copy.completionTitle,
573573+ completionMessage: copy.completionMessage,
432574 slug,
433575 blocks: {
434576 create: initialBlocks(locale),
···2323 }).format(date);
2424}
25252626+export function formatCalendarDate(value: Date | string, locale = "en") {
2727+ const date = typeof value === "string" ? new Date(`${value}T00:00:00.000Z`) : value;
2828+2929+ return new Intl.DateTimeFormat(locale, {
3030+ dateStyle: "medium",
3131+ timeZone: "UTC",
3232+ }).format(date);
3333+}
3434+2635export function sentenceCase(value: string) {
2736 return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
2837}
+2-2
lib/validators.ts
···11-import { FormBlockType } from "@prisma/client";
21import { z } from "zod";
3233+import { blockTypeSchema } from "@/lib/blocks";
44import { supportedLocales } from "@/lib/i18n";
5566export const formMetadataSchema = z
···5959 }));
60606161export const createBlockSchema = z.object({
6262- type: z.nativeEnum(FormBlockType),
6262+ type: blockTypeSchema,
6363});
64646565export const blockUpdateSchema = z.object({
+23-3
locales/en.yml
···101101 noFormsDescription: New forms start as drafts so you can edit before publishing.
102102 viewGrid: Grid
103103 viewTable: Table
104104- noDescription: No description yet.
105104 shareUrl: Share URL
106105 titleColumn: Title
107106 statusColumn: Status
···118117 newBlock: New block
119118 settingsTitle: Settings
120119 titleField: Title
121121- descriptionField: Description
122120 shareSlug: Public URL address
123121 afterSubmission: After submission
124122 afterSubmissionDescription: Customize what respondents see after they finish the form, including an optional next-step link.
···138136 saveFormSettings: Save form settings
139137 dragBlockAria: "Drag {label}"
140138 prompt: Prompt
139139+ headingField: Header
141140 supportText: Support text
142141 bodyCopy: Body copy
143142 placeholder: Placeholder
143143+ allowFloats: Allow decimal values
144144+ minimumValue: Minimum value
145145+ maximumValue: Maximum value
146146+ regexValidation: Validate with regex
147147+ regexPattern: Regex pattern
148148+ regexHelp: Use a custom regex to constrain accepted answers for this text question.
149149+ checkboxLabel: Checkbox label
144150 choiceOptions: Choice options
145151 addOption: Add option
146152 option: "Option {number}"
···173179 LONG_TEXT: Long text
174180 SINGLE_CHOICE: Single choice
175181 MULTIPLE_CHOICE: Multiple choice
182182+ NUMBER: Number
183183+ LINK: Link
184184+ AGREEMENT: Agreement
185185+ DATE: Date
176186publicRunner:
177187 responseReceived: Response received
178188 defaultCompletionTitle: Thanks for taking the time.
···180190 step: "Step {current} of {total}"
181191 context: Context
182192 defaultTextTitle: Take a breath before the next step.
183183- question: Question
184193 required: Required
185194 chooseOne: Choose one option.
186195 selectAll: Select all that apply.
187196 answerRequired: Please answer before continuing.
188197 singleChoiceRequired: Choose one option before continuing.
189198 multiChoiceRequired: Choose at least one option before continuing.
199199+ invalidNumber: Enter a valid number before continuing.
200200+ wholeNumberRequired: Enter a whole number before continuing.
201201+ numberAtLeast: "Enter a number greater than or equal to {min}."
202202+ numberAtMost: "Enter a number less than or equal to {max}."
203203+ invalidLink: Enter a valid link before continuing.
204204+ invalidDate: Enter a valid date before continuing.
205205+ openCalendar: Open calendar
206206+ agreementRequired: You must agree before continuing.
207207+ invalidTextFormat: Use the expected format before continuing.
208208+ agree: Agree
209209+ doNotAgree: Do not agree
190210 submitError: Could not submit response.
191211 back: Back
192212 continue: Continue
+23-3
locales/ru.yml
···101101 noFormsDescription: Новые формы создаются как черновики, чтобы вы могли отредактировать их перед публикацией.
102102 viewGrid: Сетка
103103 viewTable: Таблица
104104- noDescription: Описание пока не добавлено.
105104 shareUrl: Публичный URL
106105 titleColumn: Название
107106 statusColumn: Статус
···118117 newBlock: Новый блок
119118 settingsTitle: Настройки
120119 titleField: Название
121121- descriptionField: Описание
122120 shareSlug: Адрес публичного URL
123121 afterSubmission: После отправки
124122 afterSubmissionDescription: Настройте то, что увидят респонденты после завершения формы, включая необязательную ссылку на следующий шаг.
···138136 saveFormSettings: Сохранить настройки формы
139137 dragBlockAria: "Перетащить: {label}"
140138 prompt: Вопрос
139139+ headingField: Заголовок
141140 supportText: Дополнительный текст
142141 bodyCopy: Основной текст
143142 placeholder: Плейсхолдер
143143+ allowFloats: Разрешить дробные числа
144144+ minimumValue: Минимальное значение
145145+ maximumValue: Максимальное значение
146146+ regexValidation: Проверять регулярным выражением
147147+ regexPattern: Regex-шаблон
148148+ regexHelp: Используйте собственный regex, чтобы ограничить формат ответа в этом текстовом вопросе.
149149+ checkboxLabel: Текст галочки
144150 choiceOptions: Варианты ответа
145151 addOption: Добавить вариант
146152 option: "Вариант {number}"
···173179 LONG_TEXT: Длинный текст
174180 SINGLE_CHOICE: Один вариант
175181 MULTIPLE_CHOICE: Несколько вариантов
182182+ NUMBER: Число
183183+ LINK: Ссылка
184184+ AGREEMENT: Согласие
185185+ DATE: Дата
176186publicRunner:
177187 responseReceived: Ответ получен
178188 defaultCompletionTitle: Спасибо, что уделили время.
···180190 step: "Шаг {current} из {total}"
181191 context: Контекст
182192 defaultTextTitle: Сделайте паузу перед следующим шагом.
183183- question: Вопрос
184193 required: Обязательно
185194 chooseOne: Выберите один вариант.
186195 selectAll: Выберите все подходящие варианты.
187196 answerRequired: Пожалуйста, ответьте перед продолжением.
188197 singleChoiceRequired: Выберите один вариант перед продолжением.
189198 multiChoiceRequired: Выберите хотя бы один вариант перед продолжением.
199199+ invalidNumber: Введите корректное число перед продолжением.
200200+ wholeNumberRequired: Введите целое число перед продолжением.
201201+ numberAtLeast: "Введите число не меньше {min}."
202202+ numberAtMost: "Введите число не больше {max}."
203203+ invalidLink: Введите корректную ссылку перед продолжением.
204204+ invalidDate: Введите корректную дату перед продолжением.
205205+ openCalendar: Открыть календарь
206206+ agreementRequired: Чтобы продолжить, нужно согласиться.
207207+ invalidTextFormat: Используйте ожидаемый формат ответа перед продолжением.
208208+ agree: Согласен
209209+ doNotAgree: Не согласен
190210 submitError: Не удалось отправить ответ.
191211 back: Назад
192212 continue: Далее
···11+## Context
22+33+The current form system supports editorial blocks, free-text questions, and choice questions. The requested change expands that model with four structured question types: number, link, agreement, and date, and also adds optional custom regex validation for text-answer blocks. This touches multiple parts of the product at once: builder block creation and editing, runner input rendering and validation, response storage, and creator-facing response views such as exports.
44+55+The project already uses a shared block model across builder and runner. The least risky approach is to extend that existing model rather than introduce a second validation path per surface.
66+77+## Goals / Non-Goals
88+99+**Goals:**
1010+- Add four new block kinds that creators can add from the builder.
1111+- Add optional custom regex validation to text-answer blocks without introducing a separate regex block kind.
1212+- Keep the builder experience consistent with existing question editing patterns.
1313+- Enforce type-specific validation in the public runner before respondents can advance.
1414+- Preserve answers for the new block types and regex-validated text answers in submission storage and creator review flows.
1515+- Define agreement-block semantics clearly so `required` means the respondent must explicitly agree.
1616+1717+**Non-Goals:**
1818+- Adding advanced numeric behavior beyond float toggling and min/max limits, such as custom step sizes, precision rules, or unit-aware formatting.
1919+- Adding URL label previews, link unfurling, or remote URL verification.
2020+- Adding date ranges, time-of-day support, or timezone-aware scheduling behavior.
2121+- Adding a library of predefined regex templates beyond raw custom-pattern support.
2222+- Introducing e-signature, legal document acceptance history, or compliance workflows beyond a boolean agreement answer.
2323+2424+## Decisions
2525+2626+### Decision: Extend the existing block-kind model with four new first-class kinds
2727+- Add new block kinds instead of overloading existing short-text blocks with validation flags.
2828+- This keeps builder menus, runner rendering, stored answers, and exports explicit and easier to reason about.
2929+- Alternative considered: a generic text input with subtype validation. Rejected because it makes labels, icons, answer parsing, and response review more ambiguous.
3030+3131+### Decision: Use simple per-type validation with native-friendly inputs
3232+- Number blocks accept numeric values only, with optional configuration for allowing floats and enforcing minimum/maximum bounds.
3333+- Link blocks accept valid URL-like link input suitable for the product’s current public-form UX.
3434+- Date blocks accept date values only.
3535+- Agreement blocks store a boolean-style choice of agreed / not agreed.
3636+- Text-answer blocks can optionally define a custom regex pattern used for additional validation.
3737+- Alternative considered: adding richer validation rules at the same time. Rejected to keep the first release small and avoid overdesign.
3838+3939+### Decision: Model regex validation as an option on text-answer blocks
4040+- Regex validation belongs on existing short-text and long-text style answer blocks instead of becoming its own block kind.
4141+- This keeps the builder simpler and lets creators choose between plain text and constrained text within the same question type.
4242+- Alternative considered: a dedicated regex block. Rejected because the main distinction is validation behavior, not a distinct respondent interaction model.
4343+4444+### Decision: Keep required semantics type-specific but consistent
4545+- Existing required validation remains the base rule: respondents cannot advance without a valid answer.
4646+- Agreement blocks are the one special case: when required, `not agreed` is treated as incomplete/invalid and only `agreed` satisfies the requirement.
4747+- Alternative considered: treating any explicit agreement choice as a valid required answer. Rejected because it would make a required agreement block ineffective for consent collection.
4848+4949+### Decision: Reuse current response storage and review pipelines
5050+- The new blocks should fit into the existing anonymous response model and export/review surfaces without introducing a separate submission system.
5151+- Structured answers should be normalized into the existing saved-answer flow so downstream features can render them consistently.
5252+- Alternative considered: custom per-type response records. Rejected because it would add migration and review complexity without clear product benefit.
5353+5454+## Risks / Trade-offs
5555+5656+- [Validation ambiguity for links] → Define one accepted URL format strategy in implementation and use the same validation on client and server.
5757+- [Numeric parsing edge cases] → Use one normalized numeric-validation path so integer-only, float-allowed, min, and max rules behave the same in builder, runner, and submission validation.
5858+- [Unsafe or confusing regex patterns] → Treat regex as creator-provided validation configuration with clear error handling and avoid introducing features that require complex expression parsing beyond runtime validation.
5959+- [Inconsistent answer formatting in exports/review] → Normalize stored answer values so creator-facing surfaces render predictable text for number, link, agreement, and date answers.
6060+- [Agreement wording may affect respondent clarity] → Keep the block’s answer options and validation behavior explicit in localized UI copy.
6161+- [Date handling can drift across locales] → Store a normalized date value and render it consistently rather than depending on browser-specific display strings.
6262+6363+## Migration Plan
6464+6565+1. Extend the shared block type definitions and validation helpers for the new kinds, numeric constraints, and regex-enabled text validation.
6666+2. Add builder support for creating and editing the new blocks, configuring float/min/max rules for number blocks, and configuring regex on text-answer blocks.
6767+3. Add runner rendering and validation for the new answer types, numeric constraints, and regex-constrained text answers.
6868+4. Verify stored responses, creator review, and export behavior for forms that include the new blocks or regex-validated text answers.
6969+5. Ship without data migration for existing forms because this change only adds new block kinds and optional validation metadata.
7070+7171+Rollback is low risk: if needed, the new block kinds can be hidden from creation and their validation paths reverted before creators adopt them broadly.
7272+7373+## Open Questions
7474+7575+- Should link validation require fully qualified URLs such as `https://...`, or should bare domains be normalized during input handling?
7676+- Should regex validation be available on both short text and long text, or only on short text in the first version?
7777+- Should numeric min/max limits be inclusive by default? (current assumption: yes)
7878+- Should the agreement block use fixed answer labels only, or allow creator-edited agreement text in a later change?
7979+- Should date review/export surfaces use locale-specific formatting or a normalized ISO-like display by default?
···11+## Why
22+33+Creators currently can only collect free text and choice-based answers. To support more practical workflows, the form builder needs additional question types for structured values such as numbers, links, dates, and explicit consent.
44+55+## What Changes
66+77+- Add a number question block that accepts numeric answers, with optional controls for allowing floats and setting minimum/maximum values.
88+- Add a link question block that accepts only valid link values.
99+- Add an agreement block that allows only agreed / not agreed responses and treats `agreed` as required when the block is marked required.
1010+- Add a date question block that accepts date answers.
1111+- Add optional custom regex validation to text-answer blocks so creators can constrain accepted text formats.
1212+- Extend builder editing, public runner validation, saved responses, and related creator surfaces to support the new block types and regex-validated text answers consistently.
1313+1414+## Capabilities
1515+1616+### New Capabilities
1717+- None.
1818+1919+### Modified Capabilities
2020+- `conversational-form-builder`: expand the set of supported block types and add optional regex validation configuration for text-answer blocks in the builder.
2121+- `anonymous-form-runner`: validate and submit number, link, agreement, and date answers, plus regex-validated text answers, in the public runner.
2222+2323+## Impact
2424+2525+- Affected code: form builder UI, block type definitions, block validation, public form runner, response persistence, localized labels, and response review/export formatting.
2626+- APIs/data: block creation and update flows will need to accept the new block kinds, numeric constraints, regex configuration for text-answer blocks, and the resulting answer shapes.
2727+- Dependencies: no new dependency expected unless native input handling proves insufficient.
···11+## MODIFIED Requirements
22+33+### Requirement: Required question blocks are validated before advancement
44+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.
55+66+#### Scenario: Respondent skips a required short text question
77+- **WHEN** a respondent attempts to continue from a required text question without an answer
88+- **THEN** the system blocks advancement and indicates that an answer is required
99+1010+#### Scenario: Respondent skips a required multiple choice question
1111+- **WHEN** a respondent attempts to continue from a required multiple choice block without selecting any options
1212+- **THEN** the system blocks advancement and indicates that an answer is required
1313+1414+#### Scenario: Respondent does not agree to a required agreement block
1515+- **WHEN** a respondent attempts to continue from a required agreement block without selecting agreed
1616+- **THEN** the system blocks advancement and indicates that agreement is required
1717+1818+## ADDED Requirements
1919+2020+### Requirement: Structured question answers are validated by block type
2121+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.
2222+2323+#### Scenario: Respondent enters an invalid number
2424+- **WHEN** a respondent provides a non-numeric value for a number block
2525+- **THEN** the system blocks advancement and indicates that a valid number is required
2626+2727+#### Scenario: Respondent enters a float when floats are disallowed
2828+- **WHEN** a respondent provides a decimal value for a number block configured to allow only whole numbers
2929+- **THEN** the system blocks advancement and indicates that a whole number is required
3030+3131+#### Scenario: Respondent enters a value outside the allowed numeric range
3232+- **WHEN** a respondent provides a number below the configured minimum or above the configured maximum for a number block
3333+- **THEN** the system blocks advancement and indicates that the answer is outside the allowed range
3434+3535+#### Scenario: Respondent enters an invalid link
3636+- **WHEN** a respondent provides a malformed link value for a link block
3737+- **THEN** the system blocks advancement and indicates that a valid link is required
3838+3939+#### Scenario: Respondent enters an invalid date
4040+- **WHEN** a respondent provides an invalid or incomplete date value for a date block
4141+- **THEN** the system blocks advancement and indicates that a valid date is required
4242+4343+#### Scenario: Respondent answers an optional agreement block
4444+- **WHEN** a respondent selects agreed or not agreed for an optional agreement block
4545+- **THEN** the system treats the explicit selection as the saved answer for that block
4646+4747+#### Scenario: Respondent enters text that does not match a configured regex
4848+- **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
4949+- **THEN** the system blocks advancement and indicates that the answer format is invalid
5050+5151+### Requirement: Form submission preserves structured answers for supported block kinds
5252+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.
5353+5454+#### Scenario: Respondent submits a form with structured answers
5555+- **WHEN** a respondent submits a published form containing number, link, agreement, or date blocks
5656+- **THEN** the system stores those answers with the submission for later review
···11+## MODIFIED Requirements
22+33+### Requirement: Creator can manage an ordered list of supported blocks
44+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.
55+66+#### Scenario: Creator adds a block
77+- **WHEN** an authenticated creator adds a supported block type to a form in an accessible workspace
88+- **THEN** the system appends the new block to the form with a stable block identifier
99+1010+#### Scenario: Creator reorders blocks
1111+- **WHEN** an authenticated creator changes the order of blocks in a form in an accessible workspace
1212+- **THEN** the system saves the new block order for the form
1313+1414+#### Scenario: Creator removes a block
1515+- **WHEN** an authenticated creator removes a block from a form in an accessible workspace
1616+- **THEN** the system removes that block from the current form structure
1717+1818+### Requirement: Question blocks support required state and type-specific configuration
1919+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.
2020+2121+#### Scenario: Creator marks a short text question required
2222+- **WHEN** an authenticated creator enables required state for a question block
2323+- **THEN** the system saves that question block as required
2424+2525+#### Scenario: Creator edits single choice options
2626+- **WHEN** an authenticated creator updates the options for a single choice or multiple choice block
2727+- **THEN** the system saves the updated option list for that block
2828+2929+#### Scenario: Creator edits a text block
3030+- **WHEN** an authenticated creator edits a text block
3131+- **THEN** the system allows content updates without exposing answer validation settings
3232+3333+#### Scenario: Creator adds an agreement block
3434+- **WHEN** an authenticated creator adds an agreement block
3535+- **THEN** the system saves it as an answerable block that can be marked required
3636+3737+#### Scenario: Creator adds a date block
3838+- **WHEN** an authenticated creator adds a date block
3939+- **THEN** the system saves it as an answerable block that accepts date responses
4040+4141+#### Scenario: Creator configures numeric constraints on a number question
4242+- **WHEN** an authenticated creator configures a number block to disallow floats or sets minimum and maximum values
4343+- **THEN** the system saves those numeric validation settings as part of the block configuration
4444+4545+#### Scenario: Creator configures regex validation on a text question
4646+- **WHEN** an authenticated creator enables custom regex validation for a text-answer block and provides a pattern
4747+- **THEN** the system saves that validation pattern as part of the block configuration
···11+## 1. Shared block model and validation
22+33+- [x] 1.1 Extend shared block kinds, form types, and validators to include number, link, agreement, and date blocks, plus numeric constraints for number blocks and optional regex validation metadata for text-answer blocks.
44+- [x] 1.2 Update block create/update server flows to accept the new block kinds and persist their configuration safely, including float/min/max rules on number blocks and custom regex patterns on text-answer blocks.
55+- [x] 1.3 Normalize saved answer values for the new block types so downstream review/export code can consume them consistently.
66+77+## 2. Builder support
88+99+- [x] 2.1 Add the new block types to the builder add-block menu with localized labels and icons.
1010+- [x] 2.2 Update block settings panels so number, link, agreement, and date blocks can be edited with the correct required-state and type-specific UI, including float/min/max controls for number blocks.
1111+- [x] 2.3 Add builder controls for enabling and editing custom regex validation on text-answer blocks.
1212+- [x] 2.4 Ensure builder block list rows, titles, and previews render the new block kinds clearly.
1313+1414+## 3. Public runner and submission flow
1515+1616+- [x] 3.1 Render number, link, agreement, and date inputs in the public runner using inputs appropriate to each block kind.
1717+- [x] 3.2 Enforce client/server validation so respondents cannot advance with invalid number, link, date, or regex-constrained text answers, including integer-only and min/max number rules.
1818+- [x] 3.3 Implement required agreement behavior so only an explicit agreed answer satisfies a required agreement block.
1919+- [x] 3.4 Verify final submission stores structured answers for the new block types in anonymous responses and preserves regex-validated text answers normally.
2020+2121+## 4. Creator review and verification
2222+2323+- [x] 4.1 Update response review and export formatting so stored number, link, agreement, and date answers are readable to creators.
2424+- [x] 4.2 Add or update localized strings needed for builder and runner copy for the new block types and regex validation UI.
2525+- [x] 4.3 Run build and targeted manual verification covering create/edit/respond/review flows for each new block type and regex-validated text questions.
+43-1
openspec/specs/anonymous-form-runner/spec.md
···3030- **THEN** the system includes that step in the visible progress through the form
31313232### Requirement: Required question blocks are validated before advancement
3333-The system SHALL prevent a respondent from advancing past a required question block until a valid answer is provided for that block type.
3333+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.
34343535#### Scenario: Respondent skips a required short text question
3636- **WHEN** a respondent attempts to continue from a required text question without an answer
···3939#### Scenario: Respondent skips a required multiple choice question
4040- **WHEN** a respondent attempts to continue from a required multiple choice block without selecting any options
4141- **THEN** the system blocks advancement and indicates that an answer is required
4242+4343+#### Scenario: Respondent does not agree to a required agreement block
4444+- **WHEN** a respondent attempts to continue from a required agreement block without selecting agreed
4545+- **THEN** the system blocks advancement and indicates that agreement is required
4646+4747+### Requirement: Structured question answers are validated by block type
4848+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.
4949+5050+#### Scenario: Respondent enters an invalid number
5151+- **WHEN** a respondent provides a non-numeric value for a number block
5252+- **THEN** the system blocks advancement and indicates that a valid number is required
5353+5454+#### Scenario: Respondent enters a float when floats are disallowed
5555+- **WHEN** a respondent provides a decimal value for a number block configured to allow only whole numbers
5656+- **THEN** the system blocks advancement and indicates that a whole number is required
5757+5858+#### Scenario: Respondent enters a value outside the allowed numeric range
5959+- **WHEN** a respondent provides a number below the configured minimum or above the configured maximum for a number block
6060+- **THEN** the system blocks advancement and indicates that the answer is outside the allowed range
6161+6262+#### Scenario: Respondent enters an invalid link
6363+- **WHEN** a respondent provides a malformed link value for a link block
6464+- **THEN** the system blocks advancement and indicates that a valid link is required
6565+6666+#### Scenario: Respondent enters an invalid date
6767+- **WHEN** a respondent provides an invalid or incomplete date value for a date block
6868+- **THEN** the system blocks advancement and indicates that a valid date is required
6969+7070+#### Scenario: Respondent answers an optional agreement block
7171+- **WHEN** a respondent selects agreed or not agreed for an optional agreement block
7272+- **THEN** the system treats the explicit selection as the saved answer for that block
7373+7474+#### Scenario: Respondent enters text that does not match a configured regex
7575+- **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
7676+- **THEN** the system blocks advancement and indicates that the answer format is invalid
7777+7878+### Requirement: Form submission preserves structured answers for supported block kinds
7979+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.
8080+8181+#### Scenario: Respondent submits a form with structured answers
8282+- **WHEN** a respondent submits a published form containing number, link, agreement, or date blocks
8383+- **THEN** the system stores those answers with the submission for later review
42844385### Requirement: Form submission stores anonymous responses
4486The 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.
···1616- **THEN** the system saves the updated title for that form
17171818### Requirement: Creator can manage an ordered list of supported blocks
1919-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.
1919+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.
20202121#### Scenario: Creator adds a block
2222- **WHEN** an authenticated creator adds a supported block type to a form in an accessible workspace
···4242- **THEN** the builder keeps the block list navigable without requiring the full page height to expand indefinitely with the left column
43434444### Requirement: Question blocks support required state and type-specific configuration
4545-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.
4545+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.
46464747#### Scenario: Creator marks a short text question required
4848- **WHEN** an authenticated creator enables required state for a question block
···5555#### Scenario: Creator edits a text block
5656- **WHEN** an authenticated creator edits a text block
5757- **THEN** the system allows content updates without exposing answer validation settings
5858+5959+#### Scenario: Creator adds an agreement block
6060+- **WHEN** an authenticated creator adds an agreement block
6161+- **THEN** the system saves it as an answerable block that can be marked required
6262+6363+#### Scenario: Creator adds a date block
6464+- **WHEN** an authenticated creator adds a date block
6565+- **THEN** the system saves it as an answerable block that accepts date responses
6666+6767+#### Scenario: Creator configures numeric constraints on a number question
6868+- **WHEN** an authenticated creator configures a number block to disallow floats or sets minimum and maximum values
6969+- **THEN** the system saves those numeric validation settings as part of the block configuration
7070+7171+#### Scenario: Creator configures regex validation on a text question
7272+- **WHEN** an authenticated creator enables custom regex validation for a text-answer block and provides a pattern
7373+- **THEN** the system saves that validation pattern as part of the block configuration
58745975### Requirement: Creator can publish and unpublish forms
6076The 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.
···11+ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'NUMBER';
22+ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'LINK';
33+ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'AGREEMENT';
44+ALTER TYPE "FormBlockType" ADD VALUE IF NOT EXISTS 'DATE';
···11+ALTER TABLE "Form"
22+ALTER COLUMN "completionTitle" SET DEFAULT '',
33+ALTER COLUMN "completionMessage" SET DEFAULT '';
+6-2
prisma/schema.prisma
···1818 LONG_TEXT
1919 SINGLE_CHOICE
2020 MULTIPLE_CHOICE
2121+ NUMBER
2222+ LINK
2323+ AGREEMENT
2424+ DATE
2125}
22262327enum OrganizationMemberRole {
···8892 organizationId String?
8993 title String @default("Untitled form")
9094 description String @default("")
9191- completionTitle String @default("Thanks for taking the time.")
9292- completionMessage String @default("Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.")
9595+ completionTitle String @default("")
9696+ completionMessage String @default("")
9397 completionLinkLabel String?
9498 completionLinkUrl String?
9599 slug String @unique