this repo has no description
0
fork

Configure Feed

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

feat: refine dashboard and public form UI

+160 -102
+1 -1
app/f/[slug]/page.tsx
··· 12 12 } 13 13 14 14 return ( 15 - <main className="mx-auto flex min-h-screen max-w-6xl items-center justify-center px-4 py-10 sm:px-6 lg:px-10"> 15 + <main className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6 py-8 lg:px-10 lg:py-10"> 16 16 <PublicFormRunner form={form} /> 17 17 </main> 18 18 );
+3 -16
components/dashboard-form-browser.tsx
··· 2 2 3 3 import Link from "next/link"; 4 4 import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 - import { ArrowDown, ArrowRight, ArrowUp, LayoutGrid, Plus, TableProperties } from "lucide-react"; 5 + import { ArrowDown, ArrowUp, LayoutGrid, Plus, TableProperties } from "lucide-react"; 6 6 import { useEffect, useMemo, useState } from "react"; 7 7 8 8 import { createFormAction } from "@/app/(creator)/actions"; ··· 162 162 <p className="mt-3 max-w-xl text-sm leading-6 text-[var(--muted)]">{form.description || "No description yet."}</p> 163 163 </div> 164 164 <Link href={`/forms/${form.id}/edit`}> 165 - <Button variant="secondary" size="sm"> 166 - Edit 167 - </Button> 165 + <Button size="sm">Open</Button> 168 166 </Link> 169 167 </div> 170 168 171 169 <div className="mt-6 grid gap-3 text-sm text-[var(--muted)] sm:grid-cols-3"> 172 170 <div> 173 171 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Share URL</p> 174 - <p className="mt-2 truncate">/{form.slug}</p> 172 + <p className="mt-2 truncate font-mono text-[13px]">/{form.slug}</p> 175 173 </div> 176 174 <div> 177 175 <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Responses</p> ··· 183 181 </div> 184 182 </div> 185 183 186 - <div className="mt-6 flex flex-wrap gap-3"> 187 - <Link href={`/forms/${form.id}/edit`}> 188 - <Button> 189 - Open builder 190 - <ArrowRight className="size-4" /> 191 - </Button> 192 - </Link> 193 - <Link href={`/forms/${form.id}/responses`}> 194 - <Button variant="secondary">View responses</Button> 195 - </Link> 196 - </div> 197 184 </Card> 198 185 ))} 199 186 </section>
+26 -11
components/form-builder.tsx
··· 262 262 ); 263 263 264 264 const [blockDraft, setBlockDraft] = useState<BuilderBlock | null>(selectedBlock); 265 + const [choiceOptionsDraft, setChoiceOptionsDraft] = useState( 266 + selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 267 + ? (selectedBlock.config as ChoiceConfig).options.join("\n") 268 + : "", 269 + ); 265 270 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; 266 271 267 272 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); ··· 280 285 281 286 useEffect(() => { 282 287 setBlockDraft(selectedBlock); 288 + setChoiceOptionsDraft( 289 + selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 290 + ? (selectedBlock.config as ChoiceConfig).options.join("\n") 291 + : "", 292 + ); 283 293 }, [selectedBlock]); 284 294 285 295 useEffect(() => { ··· 348 358 title: blockDraft.title, 349 359 description: blockDraft.description, 350 360 required: blockDraft.required, 351 - config: blockDraft.config, 361 + config: 362 + blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE" 363 + ? { options: choiceOptionsDraft.split("\n") } 364 + : blockDraft.config, 352 365 }), 353 366 }); 354 367 ··· 565 578 </label> 566 579 <label className="space-y-2 text-sm text-[var(--muted)]"> 567 580 <span className="font-medium text-[var(--ink)]">Share URL slug</span> 568 - <Input value={metadataDraft.slug} onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} /> 581 + <Input 582 + value={metadataDraft.slug} 583 + className="font-mono text-[13px]" 584 + onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} 585 + /> 569 586 </label> 570 587 </div> 571 588 ··· 616 633 <div className="grid gap-4 rounded-[20px] border border-black/8 bg-[var(--bg-strong)] p-5 lg:grid-cols-[1fr_auto_auto] lg:items-center"> 617 634 <div> 618 635 <p className="text-xs font-semibold uppercase tracking-[0.22em] text-[var(--accent)]">Public route</p> 619 - <p className="mt-2 truncate text-sm text-[var(--ink)]">{shareHref}</p> 636 + <p className="mt-2 truncate font-mono text-[13px] text-[var(--ink)]">{shareHref}</p> 620 637 <p className="mt-1 text-xs text-[var(--muted)]"> 621 638 Published forms accept anonymous submissions. Changes to a published form go live immediately. 622 639 </p> ··· 704 721 <label className="space-y-2 text-sm text-[var(--muted)]"> 705 722 <span className="font-medium text-[var(--ink)]">Choice options</span> 706 723 <Textarea 707 - value={(blockDraft.config as ChoiceConfig).options.join("\n")} 708 - onChange={(event) => 724 + value={choiceOptionsDraft} 725 + onChange={(event) => { 726 + setChoiceOptionsDraft(event.target.value); 709 727 setBlockDraft((current) => 710 728 current 711 729 ? { 712 730 ...current, 713 731 config: { 714 - options: event.target.value 715 - .split("\n") 716 - .map((option) => option.trim()) 717 - .filter(Boolean), 732 + options: event.target.value.split("\n"), 718 733 }, 719 734 } 720 735 : current, 721 - ) 722 - } 736 + ); 737 + }} 723 738 /> 724 739 <p className="text-xs text-[var(--muted)]">Use one option per line. We’ll keep the first 10 non-empty values.</p> 725 740 </label>
+123 -69
components/public-form-runner.tsx
··· 1 1 "use client"; 2 2 3 3 import { AnimatePresence, motion } from "framer-motion"; 4 - import { ArrowLeft, ArrowRight, Check, LoaderCircle } from "lucide-react"; 4 + import { ArrowLeft, ArrowRight, Check, Circle, LoaderCircle, Square } from "lucide-react"; 5 + import Image from "next/image"; 5 6 import Link from "next/link"; 6 7 import { useMemo, useState } from "react"; 7 8 8 - import { Button } from "@/components/ui/button"; 9 + import { Button, buttonVariants } from "@/components/ui/button"; 9 10 import { Card } from "@/components/ui/card"; 10 11 import { Input } from "@/components/ui/input"; 12 + import { ToastViewport, type ToastData } from "@/components/ui/toast"; 11 13 import { Textarea } from "@/components/ui/textarea"; 12 14 import { cn } from "@/lib/utils"; 13 15 ··· 59 61 export function PublicFormRunner({ form }: { form: PublicForm }) { 60 62 const [step, setStep] = useState(0); 61 63 const [answers, setAnswers] = useState<Record<string, string | string[]>>({}); 62 - const [error, setError] = useState<string | null>(null); 64 + const [toasts, setToasts] = useState<ToastData[]>([]); 63 65 const [isSubmitting, setIsSubmitting] = useState(false); 64 66 const [isComplete, setIsComplete] = useState(false); 65 67 ··· 79 81 })); 80 82 } 81 83 84 + function showToast(message: string, variant: ToastData["variant"] = "success") { 85 + const id = crypto.randomUUID(); 86 + setToasts((current) => [...current, { id, message, variant }]); 87 + } 88 + 89 + function dismissToast(id: string) { 90 + setToasts((current) => current.filter((toast) => toast.id !== id)); 91 + } 92 + 82 93 function validateStep() { 83 94 if (!currentBlock || currentBlock.type === "TEXT") { 84 95 return true; ··· 95 106 return true; 96 107 } 97 108 98 - setError("Please answer before continuing."); 109 + showToast("Please answer before continuing.", "error"); 99 110 return false; 100 111 } 101 112 ··· 104 115 return true; 105 116 } 106 117 107 - setError("Choose one option before continuing."); 118 + showToast("Choose one option before continuing.", "error"); 108 119 return false; 109 120 } 110 121 ··· 113 124 return true; 114 125 } 115 126 116 - setError("Choose at least one option before continuing."); 127 + showToast("Choose at least one option before continuing.", "error"); 117 128 return false; 118 129 } 119 130 ··· 121 132 } 122 133 123 134 async function handleContinue() { 124 - setError(null); 125 - 126 135 if (!validateStep()) { 127 136 return; 128 137 } ··· 133 142 await submitResponse(form.slug, answers); 134 143 setIsComplete(true); 135 144 } catch (caughtError) { 136 - setError(caughtError instanceof Error ? caughtError.message : "Could not submit response."); 145 + showToast(caughtError instanceof Error ? caughtError.message : "Could not submit response.", "error"); 137 146 } finally { 138 147 setIsSubmitting(false); 139 148 } ··· 145 154 } 146 155 147 156 function handleBack() { 148 - setError(null); 149 157 setStep((current) => Math.max(0, current - 1)); 150 158 } 151 159 152 160 if (isComplete) { 153 161 return ( 154 - <Card className="w-full max-w-3xl overflow-hidden bg-[#16120f] p-10 text-white shadow-[0_36px_120px_rgba(22,18,15,0.32)]"> 155 - <p className="text-xs font-semibold uppercase tracking-[0.32em] text-[#b9dfb9]">Response received</p> 156 - <h2 className="mt-6 font-display text-5xl">{completionTitle}</h2> 157 - <p className="mt-4 max-w-2xl text-base leading-8 text-white/72">{completionMessage}</p> 158 - {completionLinkLabel && completionLinkUrl ? ( 159 - <div className="mt-8"> 160 - <Link 161 - href={completionLinkUrl} 162 - target="_blank" 163 - rel="noreferrer" 164 - className="inline-flex items-center justify-center rounded-xl bg-[#9fd7a4] px-5 py-3 text-sm font-semibold !text-[#122012] transition hover:bg-[#b8e4bb]" 165 - > 166 - {completionLinkLabel} 167 - </Link> 162 + <Card className="w-full max-w-3xl overflow-hidden"> 163 + <div className="border-b border-black/8 bg-[var(--accent-soft)]/55 px-6 py-6 sm:px-8"> 164 + <div className="flex items-center gap-4"> 165 + <Image src="/sproute.png" alt="Lively Forms" width={44} height={44} priority className="size-11" /> 166 + <div> 167 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Response received</p> 168 + <p className="mt-1 text-sm text-[var(--muted)]">Lively Forms</p> 169 + </div> 168 170 </div> 169 - ) : null} 171 + </div> 172 + <div className="px-6 py-8 sm:px-8 sm:py-10"> 173 + <h2 className="max-w-2xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl">{completionTitle}</h2> 174 + <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{completionMessage}</p> 175 + {completionLinkLabel && completionLinkUrl ? ( 176 + <div className="mt-8"> 177 + <Link 178 + href={completionLinkUrl} 179 + target="_blank" 180 + rel="noreferrer" 181 + className={buttonVariants({ className: "w-fit" })} 182 + > 183 + {completionLinkLabel} 184 + </Link> 185 + </div> 186 + ) : null} 187 + </div> 170 188 </Card> 171 189 ); 172 190 } 173 191 174 192 return ( 175 - <Card className="w-full max-w-4xl overflow-hidden bg-[#16120f] p-4 text-white shadow-[0_36px_120px_rgba(22,18,15,0.32)] sm:p-6 lg:p-8"> 176 - <div className="rounded-[20px] border border-white/10 bg-white/[0.03] p-6 sm:p-8"> 177 - <div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> 178 - <div> 179 - <p className="text-xs font-semibold uppercase tracking-[0.3em] text-[#b9dfb9]">{form.title}</p> 180 - {form.description ? <p className="mt-2 max-w-xl text-sm leading-7 text-white/62">{form.description}</p> : null} 193 + <> 194 + <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 195 + <Card className="w-full max-w-5xl overflow-hidden"> 196 + <div className="border-b border-black/8 px-6 py-6 sm:px-8 sm:py-7"> 197 + <div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between"> 198 + <div className="min-w-0"> 199 + <div className="flex items-center gap-4"> 200 + <Image src="/sproute.png" alt="Lively Forms" width={44} height={44} priority className="size-11 shrink-0" /> 201 + <div className="min-w-0"> 202 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Lively Forms</p> 203 + <h1 className="mt-2 font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl">{form.title}</h1> 204 + </div> 205 + </div> 206 + {form.description ? <p className="mt-5 max-w-2xl text-sm leading-7 text-[var(--muted)]">{form.description}</p> : null} 181 207 </div> 182 - <div className="sm:min-w-[180px]"> 183 - <div className="h-2 rounded-full bg-white/10"> 208 + 209 + <div className="w-full max-w-sm shrink-0"> 210 + <div className="flex items-center justify-between gap-3"> 211 + <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-[#2f5d35]"> 212 + Step {step + 1} of {form.blocks.length} 213 + </span> 214 + <span className="text-xs font-medium uppercase tracking-[0.2em] text-[var(--muted)]">{Math.round(progress)}%</span> 215 + </div> 216 + <div className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--bg-strong)]"> 184 217 <motion.div 185 - className="h-full rounded-full bg-[#9fd7a4]" 218 + className="h-full rounded-full bg-[var(--accent)]" 186 219 animate={{ width: `${progress}%` }} 187 220 transition={{ type: "spring", stiffness: 130, damping: 20 }} 188 221 /> 189 222 </div> 190 - <p className="mt-2 text-right text-xs uppercase tracking-[0.24em] text-white/45"> 191 - Step {step + 1} of {form.blocks.length} 192 - </p> 193 223 </div> 194 224 </div> 225 + </div> 195 226 227 + <div className="px-6 py-8 sm:px-8 sm:py-10"> 196 228 <AnimatePresence mode="wait"> 197 229 <motion.div 198 230 key={currentBlock.id} 199 - initial={{ opacity: 0, y: 18 }} 231 + initial={{ opacity: 0, y: 16 }} 200 232 animate={{ opacity: 1, y: 0 }} 201 - exit={{ opacity: 0, y: -18 }} 202 - transition={{ duration: 0.25, ease: "easeOut" }} 233 + exit={{ opacity: 0, y: -16 }} 234 + transition={{ duration: 0.24, ease: "easeOut" }} 203 235 className="min-h-[360px]" 204 236 > 205 237 {currentBlock.type === "TEXT" ? ( 206 - <div className="flex min-h-[320px] flex-col justify-center"> 207 - <p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/38">Context</p> 208 - <h2 className="mt-5 font-display text-4xl leading-tight sm:text-6xl">{currentBlock.title || "Take a breath before the next step."}</h2> 209 - {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-white/72">{currentBlock.description}</p> : null} 210 - <p className="mt-6 max-w-2xl text-lg leading-9 text-white/78">{(currentBlock.config as { body: string }).body}</p> 238 + <div className="flex min-h-[320px] flex-col justify-center rounded-[18px] bg-[var(--bg-strong)]/75 px-6 py-8 sm:px-8"> 239 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Context</p> 240 + <h2 className="mt-5 max-w-3xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl"> 241 + {currentBlock.title || "Take a breath before the next step."} 242 + </h2> 243 + {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{currentBlock.description}</p> : null} 244 + <p className="mt-6 max-w-3xl text-lg leading-9 text-[var(--ink)]/82">{(currentBlock.config as { body: string }).body}</p> 211 245 </div> 212 246 ) : ( 213 247 <div className="flex min-h-[320px] flex-col justify-center"> 214 - <p className="text-xs font-semibold uppercase tracking-[0.3em] text-white/38">Question</p> 215 - <h2 className="mt-5 font-display text-4xl leading-tight sm:text-6xl">{currentBlock.title}</h2> 216 - {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-white/72">{currentBlock.description}</p> : null} 248 + <div className="flex flex-wrap items-center gap-3"> 249 + <p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--accent)]">Question</p> 250 + {currentBlock.required ? ( 251 + <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-[#2f5d35]"> 252 + Required 253 + </span> 254 + ) : null} 255 + </div> 256 + 257 + <h2 className="mt-5 max-w-3xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl"> 258 + {currentBlock.title} 259 + </h2> 260 + {currentBlock.description ? <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{currentBlock.description}</p> : null} 217 261 218 262 <div className="mt-10 space-y-4"> 263 + {currentBlock.type === "SINGLE_CHOICE" ? ( 264 + <p className="text-sm font-medium text-[var(--muted)]">Choose one option.</p> 265 + ) : null} 266 + 267 + {currentBlock.type === "MULTIPLE_CHOICE" ? ( 268 + <p className="text-sm font-medium text-[var(--muted)]">Select all that apply.</p> 269 + ) : null} 219 270 {currentBlock.type === "SHORT_TEXT" ? ( 220 271 <Input 221 - className="h-14 rounded-[18px] border-white/10 bg-white/6 text-lg text-white placeholder:text-white/28" 272 + className="h-14 border-black/10 bg-[var(--surface-strong)] text-base text-[var(--ink)] placeholder:text-[var(--muted)]/65" 222 273 placeholder={(currentBlock.config as { placeholder: string }).placeholder} 223 274 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 224 275 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} ··· 227 278 228 279 {currentBlock.type === "LONG_TEXT" ? ( 229 280 <Textarea 230 - className="min-h-[180px] rounded-[20px] border-white/10 bg-white/6 text-lg text-white placeholder:text-white/28" 281 + className="min-h-[180px] border-black/10 bg-[var(--surface-strong)] text-base text-[var(--ink)] placeholder:text-[var(--muted)]/65" 231 282 placeholder={(currentBlock.config as { placeholder: string }).placeholder} 232 283 value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 233 284 onChange={(event) => setAnswer(currentBlock.id, event.target.value)} ··· 244 295 key={option} 245 296 type="button" 246 297 className={cn( 247 - "flex items-center justify-between rounded-[16px] border px-5 py-4 text-left transition", 298 + "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 248 299 selected 249 - ? "border-[#9fd7a4] bg-[#9fd7a4]/16 text-white" 250 - : "border-white/10 bg-white/4 text-white/78 hover:bg-white/8", 300 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[0_12px_28px_rgba(79,122,88,0.08)]" 301 + : "border-black/8 bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-white", 251 302 )} 252 303 onClick={() => setAnswer(currentBlock.id, option)} 253 304 > 254 - <span>{option}</span> 255 - {selected ? <Check className="size-4" /> : null} 305 + <div className="flex items-center gap-3"> 306 + <span className="inline-flex size-5 items-center justify-center rounded-full border border-black/12 bg-white/70"> 307 + {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Circle className="size-3.5 text-[var(--muted)]/55" />} 308 + </span> 309 + <span>{option}</span> 310 + </div> 256 311 </button> 257 312 ); 258 313 })} ··· 271 326 key={option} 272 327 type="button" 273 328 className={cn( 274 - "flex items-center justify-between rounded-[16px] border px-5 py-4 text-left transition", 329 + "flex items-center justify-between rounded-[18px] border px-5 py-4 text-left transition", 275 330 selected 276 - ? "border-[#9fd7a4] bg-[#9fd7a4]/16 text-white" 277 - : "border-white/10 bg-white/4 text-white/78 hover:bg-white/8", 331 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[0_12px_28px_rgba(79,122,88,0.08)]" 332 + : "border-black/8 bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-white", 278 333 )} 279 334 onClick={() => { 280 335 const nextValues = selected ··· 283 338 setAnswer(currentBlock.id, nextValues); 284 339 }} 285 340 > 286 - <span>{option}</span> 287 - {selected ? <Check className="size-4" /> : null} 341 + <div className="flex items-center gap-3"> 342 + <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-black/12 bg-white/70"> 343 + {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Square className="size-3.5 text-[var(--muted)]/55" />} 344 + </span> 345 + <span>{option}</span> 346 + </div> 288 347 </button> 289 348 ); 290 349 })} 291 350 </div> 292 351 ) : null} 293 352 </div> 294 - 295 - {currentBlock.required ? ( 296 - <p className="mt-4 text-xs font-semibold uppercase tracking-[0.24em] text-[#b9dfb9]">Required</p> 297 - ) : null} 298 353 </div> 299 354 )} 300 355 </motion.div> 301 356 </AnimatePresence> 302 357 303 - {error ? <p className="mt-4 text-sm text-[#f8b4b4]">{error}</p> : null} 304 - 305 - <div className="mt-8 flex items-center justify-between gap-3"> 306 - <Button variant="secondary" className="border border-white/10 bg-white/5 text-white hover:bg-white/12" onClick={handleBack} disabled={step === 0 || isSubmitting}> 358 + <div className="mt-8 flex items-center justify-between gap-3 border-t border-black/8 pt-6"> 359 + <Button variant="secondary" onClick={handleBack} disabled={step === 0 || isSubmitting}> 307 360 <ArrowLeft className="size-4" /> 308 361 Back 309 362 </Button> 310 - <Button className="bg-[#9fd7a4] text-[#122012] hover:bg-[#b8e4bb]" onClick={handleContinue} disabled={isSubmitting}> 363 + <Button onClick={handleContinue} disabled={isSubmitting}> 311 364 {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null} 312 365 {step === form.blocks.length - 1 ? "Submit response" : "Continue"} 313 366 {!isSubmitting ? <ArrowRight className="size-4" /> : null} ··· 315 368 </div> 316 369 </div> 317 370 </Card> 371 + </> 318 372 ); 319 373 }
+7 -5
lib/forms.ts
··· 228 228 } 229 229 230 230 function normalizeBlockConfig(type: FormBlockType, value: unknown): BlockConfig { 231 - const config = parseBlockConfig(type, value); 232 - 233 - if ((type === FormBlockType.SINGLE_CHOICE || type === FormBlockType.MULTIPLE_CHOICE) && "options" in config) { 234 - const options = sanitizeOptions(config.options); 231 + if (type === FormBlockType.SINGLE_CHOICE || type === FormBlockType.MULTIPLE_CHOICE) { 232 + const rawOptions = 233 + typeof value === "object" && value && "options" in value && Array.isArray(value.options) 234 + ? value.options.filter((option): option is string => typeof option === "string") 235 + : []; 236 + const options = sanitizeOptions(rawOptions); 235 237 236 238 return { 237 239 options: options.length >= 2 ? options : ["Option 1", "Option 2"], 238 240 }; 239 241 } 240 242 241 - return config; 243 + return parseBlockConfig(type, value); 242 244 } 243 245 244 246 function normalizeAnswer(block: SerializedBlock, rawValue: unknown): string | string[] | undefined {