this repo has no description
0
fork

Configure Feed

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

ci: add forgejo checks and prettier

+3585 -1271
+43
.forgejo/workflows/ci.yml
··· 1 + name: CI 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + pull_request: 8 + 9 + jobs: 10 + verify: 11 + runs-on: codeberg-small 12 + env: 13 + CI: "true" 14 + DATABASE_URL: postgresql://ci:ci@localhost:5432/lively_forms?schema=public 15 + AUTH_SECRET: ci-auth-secret-ci-auth-secret-ci-auth-secret 16 + AUTH_GOOGLE_ID: ci-google-id 17 + AUTH_GOOGLE_SECRET: ci-google-secret 18 + NEXTAUTH_URL: http://localhost:3000 19 + steps: 20 + - name: Checkout 21 + uses: https://code.forgejo.org/actions/checkout@v4 22 + 23 + - name: Install Bun 24 + run: | 25 + if ! command -v node >/dev/null 2>&1; then 26 + sudo apt-get update 27 + sudo apt-get install -y nodejs npm 28 + fi 29 + 30 + npm config set prefix "$HOME/.local" 31 + export PATH="$HOME/.local/bin:$PATH" 32 + npm install -g bun@1.3.11 33 + bun --version 34 + 35 + - name: Install dependencies 36 + run: | 37 + export PATH="$HOME/.local/bin:$PATH" 38 + bun install --frozen-lockfile 39 + 40 + - name: Verify formatting, lint, types, and build 41 + run: | 42 + export PATH="$HOME/.local/bin:$PATH" 43 + bun run check
+4
.prettierignore
··· 1 + .next 2 + node_modules 3 + openspec 4 + public
+5
README.md
··· 5 5 ## Features 6 6 7 7 ### Form building 8 + 8 9 - Master-detail form builder with block reordering 9 10 - Supported block types: 10 11 - text ··· 28 29 - Custom completion title, message, and optional follow-up link 29 30 30 31 ### Branching and flow logic 32 + 31 33 - Forward-only branching between later blocks 32 34 - Default next target when no branch rule matches 33 35 - Operator-based conditions by block type: ··· 41 43 - Submission validation only for blocks actually visited in the chosen route 42 44 43 45 ### Responses 46 + 44 47 - Anonymous public submissions 45 48 - Response list and response detail views 46 49 - Branched response review with visited and skipped route context 47 50 - CSV and XLSX response export 48 51 49 52 ### Workspaces and collaboration 53 + 50 54 - Personal workspace 51 55 - Organization workspaces 52 56 - Organization owner administration ··· 54 58 - Shared form management inside organizations 55 59 56 60 ### Creator experience 61 + 57 62 - Google sign-in for creators 58 63 - Dashboard grid and table views with sorting 59 64 - Profile settings for name, avatar, and locale
+63 -14
app/(creator)/actions.ts
··· 14 14 revokeOrganizationInviteLink, 15 15 } from "@/lib/organizations"; 16 16 import { updateProfileSettings } from "@/lib/users"; 17 - import { parseWorkspaceValue, setActiveWorkspaceCookie, getActiveWorkspaceForUser } from "@/lib/workspaces"; 17 + import { 18 + parseWorkspaceValue, 19 + setActiveWorkspaceCookie, 20 + getActiveWorkspaceForUser, 21 + } from "@/lib/workspaces"; 18 22 19 23 async function requireSessionUser() { 20 24 const session = await getServerAuthSession(); ··· 30 34 formData: FormData, 31 35 fallback?: { organizationId?: string | null; section?: string | null }, 32 36 ) { 33 - const organizationId = String(formData.get("returnOrganizationId") ?? fallback?.organizationId ?? "").trim(); 34 - const section = String(formData.get("returnSection") ?? fallback?.section ?? "").trim(); 37 + const organizationId = String( 38 + formData.get("returnOrganizationId") ?? fallback?.organizationId ?? "", 39 + ).trim(); 40 + const section = String( 41 + formData.get("returnSection") ?? fallback?.section ?? "", 42 + ).trim(); 35 43 const params = new URLSearchParams(); 36 44 37 45 if (section) { ··· 50 58 const userId = await requireSessionUser(); 51 59 const { activeWorkspace } = await getActiveWorkspaceForUser(userId); 52 60 const session = await getServerAuthSession(); 53 - const form = await createDraftForm(userId, activeWorkspace, session?.user.locale); 61 + const form = await createDraftForm( 62 + userId, 63 + activeWorkspace, 64 + session?.user.locale, 65 + ); 54 66 55 67 redirect(`/forms/${form.id}/edit`); 56 68 } ··· 61 73 const nextValue = String(formData.get("workspace") ?? "personal"); 62 74 const isValidOption = options.some((option) => option.value === nextValue); 63 75 64 - await setActiveWorkspaceCookie(isValidOption ? parseWorkspaceValue(nextValue) : { kind: "personal" }); 76 + await setActiveWorkspaceCookie( 77 + isValidOption ? parseWorkspaceValue(nextValue) : { kind: "personal" }, 78 + ); 65 79 } 66 80 67 81 export async function createOrganizationAction(formData: FormData) { 68 82 const userId = await requireSessionUser(); 69 - const organization = await createOrganization(userId, { name: formData.get("name") }); 83 + const organization = await createOrganization(userId, { 84 + name: formData.get("name"), 85 + }); 70 86 71 - await setActiveWorkspaceCookie({ kind: "organization", organizationId: organization.id }); 72 - redirect(settingsRedirectTarget(formData, { organizationId: organization.id, section: "organizations" })); 87 + await setActiveWorkspaceCookie({ 88 + kind: "organization", 89 + organizationId: organization.id, 90 + }); 91 + redirect( 92 + settingsRedirectTarget(formData, { 93 + organizationId: organization.id, 94 + section: "organizations", 95 + }), 96 + ); 73 97 } 74 98 75 99 export async function renameOrganizationAction(formData: FormData) { 76 100 const userId = await requireSessionUser(); 77 101 const organizationId = String(formData.get("organizationId") ?? ""); 78 - await renameOrganization(userId, organizationId, { name: formData.get("name") }); 79 - redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" })); 102 + await renameOrganization(userId, organizationId, { 103 + name: formData.get("name"), 104 + }); 105 + redirect( 106 + settingsRedirectTarget(formData, { 107 + organizationId, 108 + section: "organizations", 109 + }), 110 + ); 80 111 } 81 112 82 113 export async function deleteOrganizationAction(formData: FormData) { 83 114 const userId = await requireSessionUser(); 84 - await deleteOrganization(userId, String(formData.get("organizationId") ?? "")); 115 + await deleteOrganization( 116 + userId, 117 + String(formData.get("organizationId") ?? ""), 118 + ); 85 119 await setActiveWorkspaceCookie({ kind: "personal" }); 86 120 redirect(settingsRedirectTarget(formData, { section: "organizations" })); 87 121 } ··· 90 124 const userId = await requireSessionUser(); 91 125 const organizationId = String(formData.get("organizationId") ?? ""); 92 126 await createOrganizationInviteLink(userId, organizationId); 93 - redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" })); 127 + redirect( 128 + settingsRedirectTarget(formData, { 129 + organizationId, 130 + section: "organizations", 131 + }), 132 + ); 94 133 } 95 134 96 135 export async function revokeOrganizationInviteLinkAction(formData: FormData) { ··· 101 140 organizationId, 102 141 String(formData.get("inviteLinkId") ?? ""), 103 142 ); 104 - redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" })); 143 + redirect( 144 + settingsRedirectTarget(formData, { 145 + organizationId, 146 + section: "organizations", 147 + }), 148 + ); 105 149 } 106 150 107 151 export async function removeOrganizationMemberAction(formData: FormData) { ··· 112 156 organizationId, 113 157 String(formData.get("memberUserId") ?? ""), 114 158 ); 115 - redirect(settingsRedirectTarget(formData, { organizationId, section: "organizations" })); 159 + redirect( 160 + settingsRedirectTarget(formData, { 161 + organizationId, 162 + section: "organizations", 163 + }), 164 + ); 116 165 } 117 166 118 167 export async function updateProfileAction(formData: FormData) {
+5 -1
app/(creator)/dashboard/loading.tsx
··· 4 4 export default async function DashboardLoading() { 5 5 const { t } = await getRequestI18n(); 6 6 7 - return <LoadingShell label={t("loading.page", { title: t("loading.titles.dashboard") })} />; 7 + return ( 8 + <LoadingShell 9 + label={t("loading.page", { title: t("loading.titles.dashboard") })} 10 + /> 11 + ); 8 12 }
+11 -3
app/(creator)/dashboard/page.tsx
··· 13 13 const session = await getServerAuthSession(); 14 14 const { t } = await getRequestI18n(); 15 15 const workspaceState = await getActiveWorkspaceForUser(session!.user.id); 16 - const forms = await listFormsForWorkspace(session!.user.id, workspaceState.activeWorkspace); 17 - const workspaceLabel = workspaceState.activeOption.kind === "personal" ? t("workspace.personal") : workspaceState.activeOption.label; 16 + const forms = await listFormsForWorkspace( 17 + session!.user.id, 18 + workspaceState.activeWorkspace, 19 + ); 20 + const workspaceLabel = 21 + workspaceState.activeOption.kind === "personal" 22 + ? t("workspace.personal") 23 + : workspaceState.activeOption.label; 18 24 19 25 return forms.length === 0 ? ( 20 26 <div className="space-y-8"> 21 27 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-6 lg:flex-row lg:items-end lg:justify-between"> 22 28 <div className="space-y-2"> 23 - <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">{workspaceLabel}</h1> 29 + <h1 className="font-display text-4xl leading-tight text-[var(--ink)]"> 30 + {workspaceLabel} 31 + </h1> 24 32 <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]"> 25 33 {t("dashboard.description")} 26 34 </p>
+5 -1
app/(creator)/forms/[id]/edit/loading.tsx
··· 4 4 export default async function EditFormLoading() { 5 5 const { t } = await getRequestI18n(); 6 6 7 - return <LoadingShell label={t("loading.page", { title: t("loading.titles.formBuilder") })} />; 7 + return ( 8 + <LoadingShell 9 + label={t("loading.page", { title: t("loading.titles.formBuilder") })} 10 + /> 11 + ); 8 12 }
+5 -1
app/(creator)/forms/[id]/edit/page.tsx
··· 5 5 import { AppError } from "@/lib/errors"; 6 6 import { getOwnedFormForBuilder } from "@/lib/forms"; 7 7 8 - export default async function EditFormPage({ params }: { params: Promise<{ id: string }> }) { 8 + export default async function EditFormPage({ 9 + params, 10 + }: { 11 + params: Promise<{ id: string }>; 12 + }) { 9 13 const session = await getServerAuthSession(); 10 14 const { id } = await params; 11 15
+91 -24
app/(creator)/forms/[id]/responses/[responseId]/page.tsx
··· 32 32 throw error; 33 33 } 34 34 35 - const workspaceLabel = response.workspace.kind === "personal" ? t("workspace.personal") : response.workspace.label; 35 + const workspaceLabel = 36 + response.workspace.kind === "personal" 37 + ? t("workspace.personal") 38 + : response.workspace.label; 36 39 const blockTypeLabel = (type: string) => t(`blocks.types.${type}`); 37 40 38 41 return ( 39 42 <div className="space-y-6"> 40 43 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 41 44 <div> 42 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("responseDetail.title", { number: response.submissionNumber, date: formatDate(response.submittedAt, locale) })}</h1> 43 - <p className="mt-2 text-xs font-medium uppercase text-[var(--muted)]">{workspaceLabel}</p> 44 - <p className="mt-2 text-sm leading-6 text-[var(--muted)]">{t("responseDetail.description")}</p> 45 + <h1 className="font-display text-4xl text-[var(--ink)]"> 46 + {t("responseDetail.title", { 47 + number: response.submissionNumber, 48 + date: formatDate(response.submittedAt, locale), 49 + })} 50 + </h1> 51 + <p className="mt-2 text-xs font-medium uppercase text-[var(--muted)]"> 52 + {workspaceLabel} 53 + </p> 54 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 55 + {t("responseDetail.description")} 56 + </p> 45 57 </div> 46 58 <Link href={`/forms/${id}/responses`}> 47 - <Button variant="secondary">{t("responseDetail.backToResponses")}</Button> 59 + <Button variant="secondary"> 60 + {t("responseDetail.backToResponses")} 61 + </Button> 48 62 </Link> 49 63 </section> 50 64 ··· 54 68 </div> 55 69 <div className="mt-5 grid gap-6 lg:grid-cols-2"> 56 70 <div> 57 - <h2 className="font-display text-2xl text-[var(--ink)]">{t("responseDetail.visitedPath")}</h2> 71 + <h2 className="font-display text-2xl text-[var(--ink)]"> 72 + {t("responseDetail.visitedPath")} 73 + </h2> 58 74 <div className="mt-3 flex flex-wrap gap-2"> 59 75 {response.routeBlocks 60 76 .filter((entry) => entry.status === "visited") 61 77 .map((entry) => ( 62 - <Badge key={entry.block.id} className="bg-[var(--accent-soft)] text-[var(--accent-ink)]"> 78 + <Badge 79 + key={entry.block.id} 80 + className="bg-[var(--accent-soft)] text-[var(--accent-ink)]" 81 + > 63 82 {entry.block.title || blockTypeLabel(entry.block.type)} 64 83 </Badge> 65 84 ))} 66 85 </div> 67 86 </div> 68 87 <div> 69 - <h2 className="font-display text-2xl text-[var(--ink)]">{t("responseDetail.skippedPath")}</h2> 88 + <h2 className="font-display text-2xl text-[var(--ink)]"> 89 + {t("responseDetail.skippedPath")} 90 + </h2> 70 91 <div className="mt-3 flex flex-wrap gap-2"> 71 - {response.routeBlocks.filter((entry) => entry.status === "skipped").length ? ( 92 + {response.routeBlocks.filter( 93 + (entry) => entry.status === "skipped", 94 + ).length ? ( 72 95 response.routeBlocks 73 96 .filter((entry) => entry.status === "skipped") 74 97 .map((entry) => ( 75 - <Badge key={entry.block.id} className="bg-[var(--surface-strong)] text-[var(--muted)]"> 98 + <Badge 99 + key={entry.block.id} 100 + className="bg-[var(--surface-strong)] text-[var(--muted)]" 101 + > 76 102 {entry.block.title || blockTypeLabel(entry.block.type)} 77 103 </Badge> 78 104 )) 79 105 ) : ( 80 - <p className="text-sm text-[var(--muted)]">{t("responseDetail.nothingSkipped")}</p> 106 + <p className="text-sm text-[var(--muted)]"> 107 + {t("responseDetail.nothingSkipped")} 108 + </p> 81 109 )} 82 110 </div> 83 111 </div> ··· 90 118 91 119 if (block.type === "TEXT") { 92 120 return ( 93 - <Card key={block.id} className="border-dashed bg-[var(--bg-strong)] p-6"> 121 + <Card 122 + key={block.id} 123 + className="border-dashed bg-[var(--bg-strong)] p-6" 124 + > 94 125 <Badge>{t("responseDetail.contextBlock")}</Badge> 95 - <h2 className="mt-4 font-display text-3xl text-[var(--ink)]">{block.title || t("responseDetail.contextTitle")}</h2> 96 - {block.description ? <p className="mt-3 text-sm leading-7 text-[var(--muted)]">{block.description}</p> : null} 97 - {"body" in block.config ? <p className="mt-4 text-base leading-8 text-[var(--ink)]/85">{block.config.body}</p> : null} 126 + <h2 className="mt-4 font-display text-3xl text-[var(--ink)]"> 127 + {block.title || t("responseDetail.contextTitle")} 128 + </h2> 129 + {block.description ? ( 130 + <p className="mt-3 text-sm leading-7 text-[var(--muted)]"> 131 + {block.description} 132 + </p> 133 + ) : null} 134 + {"body" in block.config ? ( 135 + <p className="mt-4 text-base leading-8 text-[var(--ink)]/85"> 136 + {block.config.body} 137 + </p> 138 + ) : null} 98 139 </Card> 99 140 ); 100 141 } ··· 103 144 <Card key={block.id} className="p-6"> 104 145 <div className="flex flex-wrap items-center gap-3"> 105 146 <Badge>{blockTypeLabel(block.type)}</Badge> 106 - {block.required ? <Badge className="bg-[var(--accent-soft)] text-[var(--accent-ink)]">{t("responseDetail.required")}</Badge> : null} 147 + {block.required ? ( 148 + <Badge className="bg-[var(--accent-soft)] text-[var(--accent-ink)]"> 149 + {t("responseDetail.required")} 150 + </Badge> 151 + ) : null} 107 152 </div> 108 - <h2 className="mt-4 font-display text-3xl text-[var(--ink)]">{block.title}</h2> 109 - {block.description ? <p className="mt-3 text-sm leading-7 text-[var(--muted)]">{block.description}</p> : null} 153 + <h2 className="mt-4 font-display text-3xl text-[var(--ink)]"> 154 + {block.title} 155 + </h2> 156 + {block.description ? ( 157 + <p className="mt-3 text-sm leading-7 text-[var(--muted)]"> 158 + {block.description} 159 + </p> 160 + ) : null} 110 161 <div className="mt-6 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-5 py-4 text-[var(--ink)]"> 111 162 {Array.isArray(answer) ? ( 112 163 <div className="flex flex-wrap gap-2"> 113 164 {answer.map((value) => ( 114 - <Badge key={value} className="bg-[var(--surface-strong)] text-[var(--ink)]"> 165 + <Badge 166 + key={value} 167 + className="bg-[var(--surface-strong)] text-[var(--ink)]" 168 + > 115 169 {value} 116 170 </Badge> 117 171 ))} 118 172 </div> 119 173 ) : answer ? ( 120 174 block.type === "LINK" ? ( 121 - <Link href={answer} target="_blank" rel="noreferrer" className="break-all text-base leading-8 text-[var(--accent)] hover:underline"> 175 + <Link 176 + href={answer} 177 + target="_blank" 178 + rel="noreferrer" 179 + className="break-all text-base leading-8 text-[var(--accent)] hover:underline" 180 + > 122 181 {answer} 123 182 </Link> 124 183 ) : block.type === "DATE" ? ( 125 - <p className="whitespace-pre-wrap text-base leading-8">{formatCalendarDate(answer, locale)}</p> 184 + <p className="whitespace-pre-wrap text-base leading-8"> 185 + {formatCalendarDate(answer, locale)} 186 + </p> 126 187 ) : block.type === "AGREEMENT" ? ( 127 188 <p className="whitespace-pre-wrap text-base leading-8"> 128 - {answer === AGREEMENT_ANSWER_VALUES.AGREED ? t("publicRunner.agree") : t("publicRunner.doNotAgree")} 189 + {answer === AGREEMENT_ANSWER_VALUES.AGREED 190 + ? t("publicRunner.agree") 191 + : t("publicRunner.doNotAgree")} 129 192 </p> 130 193 ) : ( 131 - <p className="whitespace-pre-wrap text-base leading-8">{answer}</p> 194 + <p className="whitespace-pre-wrap text-base leading-8"> 195 + {answer} 196 + </p> 132 197 ) 133 198 ) : ( 134 - <p className="text-sm text-[var(--muted)]">{t("responseDetail.noAnswer")}</p> 199 + <p className="text-sm text-[var(--muted)]"> 200 + {t("responseDetail.noAnswer")} 201 + </p> 135 202 )} 136 203 </div> 137 204 </Card>
+5 -1
app/(creator)/forms/[id]/responses/loading.tsx
··· 4 4 export default async function ResponsesLoading() { 5 5 const { t } = await getRequestI18n(); 6 6 7 - return <LoadingShell label={t("loading.page", { title: t("loading.titles.responses") })} />; 7 + return ( 8 + <LoadingShell 9 + label={t("loading.page", { title: t("loading.titles.responses") })} 10 + /> 11 + ); 8 12 }
+33 -8
app/(creator)/forms/[id]/responses/page.tsx
··· 13 13 import { getRequestI18n } from "@/lib/i18n-server"; 14 14 import { formatDate } from "@/lib/utils"; 15 15 16 - export default async function ResponsesPage({ params }: { params: Promise<{ id: string }> }) { 16 + export default async function ResponsesPage({ 17 + params, 18 + }: { 19 + params: Promise<{ id: string }>; 20 + }) { 17 21 const session = await getServerAuthSession(); 18 22 const { locale, t } = await getRequestI18n(); 19 23 const { id } = await params; ··· 33 37 throw error; 34 38 } 35 39 36 - const workspaceLabel = form.workspace.kind === "personal" ? t("workspace.personal") : form.workspace.label; 40 + const workspaceLabel = 41 + form.workspace.kind === "personal" 42 + ? t("workspace.personal") 43 + : form.workspace.label; 37 44 38 45 return ( 39 46 <div className="space-y-5"> 40 47 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 41 48 <div> 42 - <h1 className="font-display text-4xl text-[var(--ink)]">{form.title}</h1> 43 - <p className="mt-2 text-xs font-medium uppercase text-[var(--muted)]">{workspaceLabel}</p> 49 + <h1 className="font-display text-4xl text-[var(--ink)]"> 50 + {form.title} 51 + </h1> 52 + <p className="mt-2 text-xs font-medium uppercase text-[var(--muted)]"> 53 + {workspaceLabel} 54 + </p> 44 55 </div> 45 56 <div className="flex flex-wrap gap-3 lg:justify-end"> 46 - <Badge>{t("responses.submissionsCount", { count: responses.length })}</Badge> 57 + <Badge> 58 + {t("responses.submissionsCount", { count: responses.length })} 59 + </Badge> 47 60 <Link href={`/forms/${form.id}/edit`}> 48 61 <Button variant="secondary">{t("responses.backToBuilder")}</Button> 49 62 </Link> ··· 66 79 <Card className="overflow-hidden p-0"> 67 80 <div className="divide-y divide-[color:var(--line)]"> 68 81 {responses.map((response) => ( 69 - <div key={response.id} className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> 82 + <div 83 + key={response.id} 84 + className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4" 85 + > 70 86 <div className="min-w-0"> 71 87 <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3"> 72 - <h2 className="font-display text-2xl text-[var(--ink)]">{t("responses.submissionTitle", { number: response.submissionNumber, date: formatDate(response.submittedAt, locale) })}</h2> 73 - <p className="text-sm text-[var(--muted)]">{t("responses.answersCount", { count: response.answerCount })}</p> 88 + <h2 className="font-display text-2xl text-[var(--ink)]"> 89 + {t("responses.submissionTitle", { 90 + number: response.submissionNumber, 91 + date: formatDate(response.submittedAt, locale), 92 + })} 93 + </h2> 94 + <p className="text-sm text-[var(--muted)]"> 95 + {t("responses.answersCount", { 96 + count: response.answerCount, 97 + })} 98 + </p> 74 99 </div> 75 100 </div> 76 101 <div className="sm:flex sm:justify-end">
+24 -5
app/(creator)/layout.tsx
··· 6 6 import { WorkspaceSwitcher } from "@/components/workspace-switcher"; 7 7 import { getServerAuthSession } from "@/lib/auth"; 8 8 import { getRequestI18n } from "@/lib/i18n-server"; 9 - import { getActiveWorkspaceForUser, serializeWorkspaceValue } from "@/lib/workspaces"; 9 + import { 10 + getActiveWorkspaceForUser, 11 + serializeWorkspaceValue, 12 + } from "@/lib/workspaces"; 10 13 11 - export default async function CreatorLayout({ children }: { children: React.ReactNode }) { 14 + export default async function CreatorLayout({ 15 + children, 16 + }: { 17 + children: React.ReactNode; 18 + }) { 12 19 const session = await getServerAuthSession(); 13 20 const { t } = await getRequestI18n(); 14 21 const appName = t("app.name"); ··· 28 35 aria-label={t("app.goToDashboardAria")} 29 36 className="shrink-0 transition-transform hover:scale-[1.02]" 30 37 > 31 - <Image src="/sproute.png" alt={appName} width={48} height={48} priority className="size-12" /> 38 + <Image 39 + src="/sproute.png" 40 + alt={appName} 41 + width={48} 42 + height={48} 43 + priority 44 + className="size-12" 45 + /> 32 46 </Link> 33 - <Link href="/dashboard" className="font-display text-2xl text-[var(--ink)] transition hover:text-[var(--accent)]"> 47 + <Link 48 + href="/dashboard" 49 + className="font-display text-2xl text-[var(--ink)] transition hover:text-[var(--accent)]" 50 + > 34 51 {appName} 35 52 </Link> 36 53 </div> 37 54 <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:gap-5"> 38 55 <WorkspaceSwitcher 39 56 options={workspaceState.options} 40 - activeValue={serializeWorkspaceValue(workspaceState.activeWorkspace)} 57 + activeValue={serializeWorkspaceValue( 58 + workspaceState.activeWorkspace, 59 + )} 41 60 /> 42 61 <AccountMenu user={session.user} /> 43 62 </div>
+8 -5
app/(creator)/settings/page.tsx
··· 6 6 export default async function SettingsPage({ 7 7 searchParams, 8 8 }: { 9 - searchParams: Promise<{ organization?: string | string[]; section?: string | string[] }>; 9 + searchParams: Promise<{ 10 + organization?: string | string[]; 11 + section?: string | string[]; 12 + }>; 10 13 }) { 11 14 const session = await getServerAuthSession(); 12 15 const [organizations, profileUser] = await Promise.all([ ··· 15 18 ]); 16 19 const resolvedSearchParams = await searchParams; 17 20 const initialOrganizationId = Array.isArray(resolvedSearchParams.organization) 18 - ? resolvedSearchParams.organization[0] ?? null 19 - : resolvedSearchParams.organization ?? null; 21 + ? (resolvedSearchParams.organization[0] ?? null) 22 + : (resolvedSearchParams.organization ?? null); 20 23 const initialSection = Array.isArray(resolvedSearchParams.section) 21 - ? resolvedSearchParams.section[0] ?? "profile" 22 - : resolvedSearchParams.section ?? "profile"; 24 + ? (resolvedSearchParams.section[0] ?? "profile") 25 + : (resolvedSearchParams.section ?? "profile"); 23 26 24 27 return ( 25 28 <SettingsShell
+14 -3
app/api/forms/[formId]/blocks/[blockId]/route.ts
··· 4 4 import { getServerAuthSession } from "@/lib/auth"; 5 5 import { deleteOwnedBlock, updateOwnedBlock } from "@/lib/forms"; 6 6 7 - export async function PATCH(request: Request, context: { params: Promise<{ formId: string; blockId: string }> }) { 7 + export async function PATCH( 8 + request: Request, 9 + context: { params: Promise<{ formId: string; blockId: string }> }, 10 + ) { 8 11 const session = await getServerAuthSession(); 9 12 10 13 if (!session?.user?.id) { ··· 14 17 try { 15 18 const payload = await request.json(); 16 19 const { formId, blockId } = await context.params; 17 - const block = await updateOwnedBlock(session.user.id, formId, blockId, payload); 20 + const block = await updateOwnedBlock( 21 + session.user.id, 22 + formId, 23 + blockId, 24 + payload, 25 + ); 18 26 19 27 return NextResponse.json({ block }); 20 28 } catch (error) { ··· 22 30 } 23 31 } 24 32 25 - export async function DELETE(_: Request, context: { params: Promise<{ formId: string; blockId: string }> }) { 33 + export async function DELETE( 34 + _: Request, 35 + context: { params: Promise<{ formId: string; blockId: string }> }, 36 + ) { 26 37 const session = await getServerAuthSession(); 27 38 28 39 if (!session?.user?.id) {
+4 -1
app/api/forms/[formId]/blocks/reorder/route.ts
··· 4 4 import { getServerAuthSession } from "@/lib/auth"; 5 5 import { reorderOwnedBlocks } from "@/lib/forms"; 6 6 7 - export async function POST(request: Request, context: { params: Promise<{ formId: string }> }) { 7 + export async function POST( 8 + request: Request, 9 + context: { params: Promise<{ formId: string }> }, 10 + ) { 8 11 const session = await getServerAuthSession(); 9 12 10 13 if (!session?.user?.id) {
+10 -2
app/api/forms/[formId]/blocks/route.ts
··· 4 4 import { getServerAuthSession } from "@/lib/auth"; 5 5 import { addOwnedBlock } from "@/lib/forms"; 6 6 7 - export async function POST(request: Request, context: { params: Promise<{ formId: string }> }) { 7 + export async function POST( 8 + request: Request, 9 + context: { params: Promise<{ formId: string }> }, 10 + ) { 8 11 const session = await getServerAuthSession(); 9 12 10 13 if (!session?.user?.id) { ··· 14 17 try { 15 18 const payload = await request.json(); 16 19 const { formId } = await context.params; 17 - const block = await addOwnedBlock(session.user.id, formId, payload, session.user.locale); 20 + const block = await addOwnedBlock( 21 + session.user.id, 22 + formId, 23 + payload, 24 + session.user.locale, 25 + ); 18 26 19 27 return NextResponse.json({ block }); 20 28 } catch (error) {
+17 -4
app/api/forms/[formId]/export/route.ts
··· 1 1 import { handleRouteError } from "@/lib/api"; 2 2 import { getServerAuthSession } from "@/lib/auth"; 3 - import { createOwnedFormResponseExport, parseResponseExportFormat } from "@/lib/response-exports"; 3 + import { 4 + createOwnedFormResponseExport, 5 + parseResponseExportFormat, 6 + } from "@/lib/response-exports"; 4 7 5 - export async function GET(request: Request, context: { params: Promise<{ formId: string }> }) { 8 + export async function GET( 9 + request: Request, 10 + context: { params: Promise<{ formId: string }> }, 11 + ) { 6 12 const session = await getServerAuthSession(); 7 13 8 14 if (!session?.user?.id) { ··· 11 17 12 18 try { 13 19 const { formId } = await context.params; 14 - const format = parseResponseExportFormat(new URL(request.url).searchParams.get("format")); 15 - const exportFile = await createOwnedFormResponseExport(session.user.id, formId, format, session.user.locale); 20 + const format = parseResponseExportFormat( 21 + new URL(request.url).searchParams.get("format"), 22 + ); 23 + const exportFile = await createOwnedFormResponseExport( 24 + session.user.id, 25 + formId, 26 + format, 27 + session.user.locale, 28 + ); 16 29 17 30 return new Response(exportFile.body, { 18 31 status: 200,
+10 -2
app/api/forms/[formId]/publish/route.ts
··· 4 4 import { getServerAuthSession } from "@/lib/auth"; 5 5 import { setOwnedFormPublished } from "@/lib/forms"; 6 6 7 - export async function POST(request: Request, context: { params: Promise<{ formId: string }> }) { 7 + export async function POST( 8 + request: Request, 9 + context: { params: Promise<{ formId: string }> }, 10 + ) { 8 11 const session = await getServerAuthSession(); 9 12 10 13 if (!session?.user?.id) { ··· 14 17 try { 15 18 const payload = await request.json(); 16 19 const { formId } = await context.params; 17 - const form = await setOwnedFormPublished(session.user.id, formId, payload, session.user.locale); 20 + const form = await setOwnedFormPublished( 21 + session.user.id, 22 + formId, 23 + payload, 24 + session.user.locale, 25 + ); 18 26 19 27 return NextResponse.json({ form }); 20 28 } catch (error) {
+13 -3
app/api/forms/[formId]/route.ts
··· 4 4 import { getServerAuthSession } from "@/lib/auth"; 5 5 import { deleteOwnedForm, updateOwnedFormMetadata } from "@/lib/forms"; 6 6 7 - export async function PATCH(request: Request, context: { params: Promise<{ formId: string }> }) { 7 + export async function PATCH( 8 + request: Request, 9 + context: { params: Promise<{ formId: string }> }, 10 + ) { 8 11 const session = await getServerAuthSession(); 9 12 10 13 if (!session?.user?.id) { ··· 14 17 try { 15 18 const payload = await request.json(); 16 19 const { formId } = await context.params; 17 - const form = await updateOwnedFormMetadata(session.user.id, formId, payload); 20 + const form = await updateOwnedFormMetadata( 21 + session.user.id, 22 + formId, 23 + payload, 24 + ); 18 25 19 26 return NextResponse.json({ form }); 20 27 } catch (error) { ··· 22 29 } 23 30 } 24 31 25 - export async function DELETE(_request: Request, context: { params: Promise<{ formId: string }> }) { 32 + export async function DELETE( 33 + _request: Request, 34 + context: { params: Promise<{ formId: string }> }, 35 + ) { 26 36 const session = await getServerAuthSession(); 27 37 28 38 if (!session?.user?.id) {
+37 -7
app/error.tsx
··· 7 7 import { Card } from "@/components/ui/card"; 8 8 import { DEFAULT_LOCALE, normalizeLocale, type AppLocale } from "@/lib/i18n"; 9 9 10 - const errorMessages: Record<AppLocale, { eyebrow: string; title: string; description: string; tryAgain: string; footer: { poweredBy: string; appName: string; licensedUnder: string; sourceCode: string; reportBug: string } }> = { 10 + const errorMessages: Record< 11 + AppLocale, 12 + { 13 + eyebrow: string; 14 + title: string; 15 + description: string; 16 + tryAgain: string; 17 + footer: { 18 + poweredBy: string; 19 + appName: string; 20 + licensedUnder: string; 21 + sourceCode: string; 22 + reportBug: string; 23 + }; 24 + } 25 + > = { 11 26 en: { 12 27 eyebrow: "Something broke", 13 28 title: "The forms hit an unexpected error.", 14 - description: "Try the action again. If it keeps happening, check your environment variables, Google OAuth setup, and local Postgres container.", 29 + description: 30 + "Try the action again. If it keeps happening, check your environment variables, Google OAuth setup, and local Postgres container.", 15 31 tryAgain: "Try again", 16 32 footer: { 17 33 poweredBy: "Built with", ··· 24 40 ru: { 25 41 eyebrow: "Что-то сломалось", 26 42 title: "В формах произошла непредвиденная ошибка.", 27 - description: "Попробуйте выполнить действие ещё раз. Если это повторяется, проверьте переменные окружения, настройку Google OAuth и локальный контейнер Postgres.", 43 + description: 44 + "Попробуйте выполнить действие ещё раз. Если это повторяется, проверьте переменные окружения, настройку Google OAuth и локальный контейнер Postgres.", 28 45 tryAgain: "Попробовать снова", 29 46 footer: { 30 47 poweredBy: "Сделано с помощью", ··· 36 53 }, 37 54 }; 38 55 39 - export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { 40 - const locale: AppLocale = typeof window === "undefined" ? DEFAULT_LOCALE : normalizeLocale(window.navigator.language); 56 + export default function GlobalError({ 57 + error, 58 + reset, 59 + }: { 60 + error: Error & { digest?: string }; 61 + reset: () => void; 62 + }) { 63 + const locale: AppLocale = 64 + typeof window === "undefined" 65 + ? DEFAULT_LOCALE 66 + : normalizeLocale(window.navigator.language); 41 67 42 68 useEffect(() => { 43 69 console.error(error); ··· 51 77 <div className="flex min-h-screen flex-col"> 52 78 <main className="mx-auto flex max-w-3xl flex-1 items-center px-6 py-16"> 53 79 <Card className="w-full px-8 py-10 text-center"> 54 - <h1 className="font-display text-4xl text-[var(--ink)]">{copy.title}</h1> 55 - <p className="mx-auto mt-3 max-w-lg text-sm leading-7 text-[var(--muted)]">{copy.description}</p> 80 + <h1 className="font-display text-4xl text-[var(--ink)]"> 81 + {copy.title} 82 + </h1> 83 + <p className="mx-auto mt-3 max-w-lg text-sm leading-7 text-[var(--muted)]"> 84 + {copy.description} 85 + </p> 56 86 <div className="mt-6 flex justify-center"> 57 87 <Button onClick={() => reset()}>{copy.tryAgain}</Button> 58 88 </div>
+5 -1
app/f/[slug]/loading.tsx
··· 4 4 export default async function PublicFormLoading() { 5 5 const { t } = await getRequestI18n(); 6 6 7 - return <LoadingShell label={t("loading.page", { title: t("loading.titles.publicForm") })} />; 7 + return ( 8 + <LoadingShell 9 + label={t("loading.page", { title: t("loading.titles.publicForm") })} 10 + /> 11 + ); 8 12 }
+5 -1
app/f/[slug]/page.tsx
··· 3 3 import { PublicFormRunner } from "@/components/public-form-runner"; 4 4 import { getPublicFormBySlug } from "@/lib/forms"; 5 5 6 - export default async function PublicFormPage({ params }: { params: Promise<{ slug: string }> }) { 6 + export default async function PublicFormPage({ 7 + params, 8 + }: { 9 + params: Promise<{ slug: string }>; 10 + }) { 7 11 const { slug } = await params; 8 12 const form = await getPublicFormBySlug(slug); 9 13
+5 -1
app/globals.css
··· 80 80 body { 81 81 min-height: 100vh; 82 82 background-color: var(--bg-end); 83 - background-image: linear-gradient(180deg, var(--bg-start) 0%, var(--bg-end) 100%); 83 + background-image: linear-gradient( 84 + 180deg, 85 + var(--bg-start) 0%, 86 + var(--bg-end) 100% 87 + ); 84 88 color: var(--ink); 85 89 font-family: var(--font-inter), sans-serif; 86 90 }
+14 -4
app/join/[token]/page.tsx
··· 8 8 import { getRequestI18n } from "@/lib/i18n-server"; 9 9 import { getOrganizationInviteState } from "@/lib/organizations"; 10 10 11 - export default async function JoinOrganizationPage({ params }: { params: Promise<{ token: string }> }) { 11 + export default async function JoinOrganizationPage({ 12 + params, 13 + }: { 14 + params: Promise<{ token: string }>; 15 + }) { 12 16 const session = await getServerAuthSession(); 13 17 const { t } = await getRequestI18n(); 14 18 ··· 24 28 <Card className="w-full p-8 lg:p-10"> 25 29 {invite ? ( 26 30 <> 27 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("join.joinTitle", { organization: invite.organizationName })}</h1> 31 + <h1 className="font-display text-4xl text-[var(--ink)]"> 32 + {t("join.joinTitle", { organization: invite.organizationName })} 33 + </h1> 28 34 <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 29 - {invite.alreadyMember ? t("join.alreadyMember") : t("join.inviteDescription")} 35 + {invite.alreadyMember 36 + ? t("join.alreadyMember") 37 + : t("join.inviteDescription")} 30 38 </p> 31 39 32 40 <div className="mt-8 flex flex-wrap gap-3"> ··· 47 55 </> 48 56 ) : ( 49 57 <> 50 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("join.unavailableTitle")}</h1> 58 + <h1 className="font-display text-4xl text-[var(--ink)]"> 59 + {t("join.unavailableTitle")} 60 + </h1> 51 61 <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 52 62 {t("join.unavailableDescription")} 53 63 </p>
+8 -2
app/join/actions.ts
··· 14 14 } 15 15 16 16 const token = String(formData.get("token") ?? ""); 17 - const { organization } = await acceptOrganizationInvite(session.user.id, token); 17 + const { organization } = await acceptOrganizationInvite( 18 + session.user.id, 19 + token, 20 + ); 18 21 19 - await setActiveWorkspaceCookie({ kind: "organization", organizationId: organization.id }); 22 + await setActiveWorkspaceCookie({ 23 + kind: "organization", 24 + organizationId: organization.id, 25 + }); 20 26 redirect("/dashboard"); 21 27 }
+3 -1
app/not-found.tsx
··· 9 9 return ( 10 10 <main className="mx-auto flex max-w-3xl flex-1 items-center px-6 py-16"> 11 11 <Card className="w-full px-8 py-10 text-center"> 12 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("notFound.title")}</h1> 12 + <h1 className="font-display text-4xl text-[var(--ink)]"> 13 + {t("notFound.title")} 14 + </h1> 13 15 <p className="mx-auto mt-3 max-w-lg text-sm leading-7 text-[var(--muted)]"> 14 16 {t("notFound.description")} 15 17 </p>
+11 -3
app/page.tsx
··· 19 19 <Card className="w-full max-w-3xl p-8 lg:p-10"> 20 20 <div className="max-w-2xl"> 21 21 <div className="flex items-center gap-4"> 22 - <Image src="/sproute.png" alt={appName} width={72} height={72} priority className="size-18" /> 23 - <h1 className="font-display text-5xl leading-none text-[var(--ink)] sm:text-6xl">{appName}</h1> 22 + <Image 23 + src="/sproute.png" 24 + alt={appName} 25 + width={72} 26 + height={72} 27 + priority 28 + className="size-18" 29 + /> 30 + <h1 className="font-display text-5xl leading-none text-[var(--ink)] sm:text-6xl"> 31 + {appName} 32 + </h1> 24 33 </div> 25 34 <p className="mt-6 max-w-xl text-base leading-7 text-[var(--muted)] sm:text-lg"> 26 35 {t("home.description")} ··· 38 47 <GoogleSignInButton /> 39 48 )} 40 49 </div> 41 - 42 50 </div> 43 51 </Card> 44 52 </section>
+3
bun.lock
··· 36 36 "@types/react-dom": "19.2.3", 37 37 "eslint": "9.39.1", 38 38 "eslint-config-next": "16.2.3", 39 + "prettier": "^3.8.2", 39 40 "prisma": "7.7.0", 40 41 "tailwindcss": "4.2.2", 41 42 "typescript": "6.0.2", ··· 1004 1005 "preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="], 1005 1006 1006 1007 "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 1008 + 1009 + "prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="], 1007 1010 1008 1011 "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], 1009 1012
+23 -5
components/account-menu.tsx
··· 41 41 aria-expanded={isOpen} 42 42 aria-label={t("account.openMenuAria")} 43 43 > 44 - <UserAvatar user={user} className="size-11" initialsClassName="text-xs" /> 44 + <UserAvatar 45 + user={user} 46 + className="size-11" 47 + initialsClassName="text-xs" 48 + /> 45 49 </button> 46 50 47 51 {isOpen ? ( 48 52 <div className="absolute right-0 top-[calc(100%+0.75rem)] z-30 min-w-[240px] overflow-hidden rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-2 shadow-[var(--shadow-card)]"> 49 53 <div className="flex items-center gap-3 rounded-[12px] px-3 py-3"> 50 - <UserAvatar user={user} className="size-10" initialsClassName="text-xs" /> 54 + <UserAvatar 55 + user={user} 56 + className="size-10" 57 + initialsClassName="text-xs" 58 + /> 51 59 <div className="min-w-0"> 52 - <p className="truncate text-sm font-semibold text-[var(--ink)]">{displayName}</p> 53 - {user.email ? <p className="truncate text-xs text-[var(--muted)]">{user.email}</p> : null} 60 + <p className="truncate text-sm font-semibold text-[var(--ink)]"> 61 + {displayName} 62 + </p> 63 + {user.email ? ( 64 + <p className="truncate text-xs text-[var(--muted)]"> 65 + {user.email} 66 + </p> 67 + ) : null} 54 68 </div> 55 69 </div> 56 70 <div className="mt-1 grid gap-1"> ··· 72 86 }) 73 87 } 74 88 > 75 - {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <LogOut className="size-4" />} 89 + {isPending ? ( 90 + <LoaderCircle className="size-4 animate-spin" /> 91 + ) : ( 92 + <LogOut className="size-4" /> 93 + )} 76 94 {t("account.logout")} 77 95 </button> 78 96 </div>
+5 -1
components/auth/google-sign-in-button.tsx
··· 20 20 }) 21 21 } 22 22 > 23 - {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <LogIn className="size-4" />} 23 + {isPending ? ( 24 + <LoaderCircle className="size-4 animate-spin" /> 25 + ) : ( 26 + <LogIn className="size-4" /> 27 + )} 24 28 {t("auth.continueWithGoogle")} 25 29 </Button> 26 30 );
+5 -1
components/auth/sign-out-button.tsx
··· 20 20 }) 21 21 } 22 22 > 23 - {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <LogOut className="size-4" />} 23 + {isPending ? ( 24 + <LoaderCircle className="size-4 animate-spin" /> 25 + ) : ( 26 + <LogOut className="size-4" /> 27 + )} 24 28 Sign out 25 29 </Button> 26 30 );
+87 -24
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, ArrowUp, LayoutGrid, Plus, TableProperties } from "lucide-react"; 5 + import { 6 + ArrowDown, 7 + ArrowUp, 8 + LayoutGrid, 9 + Plus, 10 + TableProperties, 11 + } from "lucide-react"; 6 12 import { useMemo, useState } from "react"; 7 13 8 14 import { createFormAction } from "@/app/(creator)/actions"; ··· 26 32 return left.localeCompare(right, undefined, { sensitivity: "base" }); 27 33 } 28 34 29 - export function DashboardFormBrowser({ forms, workspaceLabel }: { forms: FormListItem[]; workspaceLabel: string }) { 35 + export function DashboardFormBrowser({ 36 + forms, 37 + workspaceLabel, 38 + }: { 39 + forms: FormListItem[]; 40 + workspaceLabel: string; 41 + }) { 30 42 const router = useRouter(); 31 43 const { locale, t } = useI18n(); 32 44 const pathname = usePathname(); 33 45 const searchParams = useSearchParams(); 34 - const view: DashboardView = searchParams.get("view") === "table" ? "table" : "grid"; 46 + const view: DashboardView = 47 + searchParams.get("view") === "table" ? "table" : "grid"; 35 48 36 49 const [sortKey, setSortKey] = useState<SortKey>("updatedAt"); 37 50 const [sortDirection, setSortDirection] = useState<SortDirection>("desc"); ··· 46 59 } 47 60 48 61 const nextQuery = nextParams.toString(); 49 - router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false }); 62 + router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { 63 + scroll: false, 64 + }); 50 65 } 51 66 52 67 const sortedForms = useMemo(() => { ··· 66 81 comparison = left.responseCount - right.responseCount; 67 82 break; 68 83 case "updatedAt": 69 - comparison = new Date(left.updatedAt).getTime() - new Date(right.updatedAt).getTime(); 84 + comparison = 85 + new Date(left.updatedAt).getTime() - 86 + new Date(right.updatedAt).getTime(); 70 87 break; 71 88 } 72 89 ··· 87 104 } 88 105 89 106 setSortKey(nextKey); 90 - setSortDirection(nextKey === "updatedAt" || nextKey === "responseCount" ? "desc" : "asc"); 107 + setSortDirection( 108 + nextKey === "updatedAt" || nextKey === "responseCount" ? "desc" : "asc", 109 + ); 91 110 } 92 111 93 112 function renderSortIcon(key: SortKey) { ··· 95 114 return <ArrowDown className="size-3.5 opacity-35" />; 96 115 } 97 116 98 - return sortDirection === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />; 117 + return sortDirection === "asc" ? ( 118 + <ArrowUp className="size-3.5" /> 119 + ) : ( 120 + <ArrowDown className="size-3.5" /> 121 + ); 99 122 } 100 123 101 124 return ( 102 125 <div className="space-y-8"> 103 126 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-6 lg:flex-row lg:items-end lg:justify-between"> 104 127 <div className="space-y-2"> 105 - <h1 className="font-display text-4xl leading-tight text-[var(--ink)]">{workspaceLabel}</h1> 106 - <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]">{t("dashboard.description")}</p> 128 + <h1 className="font-display text-4xl leading-tight text-[var(--ink)]"> 129 + {workspaceLabel} 130 + </h1> 131 + <p className="max-w-2xl text-sm leading-6 text-[var(--muted)]"> 132 + {t("dashboard.description")} 133 + </p> 107 134 </div> 108 135 <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> 109 136 <div className="inline-flex h-12 items-center gap-1 rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] p-1"> ··· 112 139 onClick={() => setDashboardView("grid")} 113 140 className={cn( 114 141 "inline-flex h-full items-center gap-2 rounded-lg px-3 text-sm font-medium transition", 115 - view === "grid" ? "bg-[var(--ink)] text-[var(--bg)]" : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 142 + view === "grid" 143 + ? "bg-[var(--ink)] text-[var(--bg)]" 144 + : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 116 145 )} 117 146 aria-pressed={view === "grid"} 118 147 > ··· 124 153 onClick={() => setDashboardView("table")} 125 154 className={cn( 126 155 "inline-flex h-full items-center gap-2 rounded-lg px-3 text-sm font-medium transition", 127 - view === "table" ? "bg-[var(--ink)] text-[var(--bg)]" : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 156 + view === "table" 157 + ? "bg-[var(--ink)] text-[var(--bg)]" 158 + : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 128 159 )} 129 160 aria-pressed={view === "table"} 130 161 > ··· 148 179 <div className="flex items-start justify-between gap-4"> 149 180 <div> 150 181 <div className="flex flex-wrap items-center gap-3"> 151 - <h2 className="font-display text-3xl text-[var(--ink)]">{form.title}</h2> 182 + <h2 className="font-display text-3xl text-[var(--ink)]"> 183 + {form.title} 184 + </h2> 152 185 <FormStatusBadge status={form.status} /> 153 186 </div> 154 187 </div> ··· 159 192 160 193 <div className="mt-6 grid gap-3 text-sm text-[var(--muted)] sm:grid-cols-3"> 161 194 <div> 162 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("dashboard.shareUrl")}</p> 163 - <p className="mt-2 truncate font-mono text-[13px]">/{form.slug}</p> 195 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 196 + {t("dashboard.shareUrl")} 197 + </p> 198 + <p className="mt-2 truncate font-mono text-[13px]"> 199 + /{form.slug} 200 + </p> 164 201 </div> 165 202 <div> 166 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("dashboard.responses")}</p> 203 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 204 + {t("dashboard.responses")} 205 + </p> 167 206 <p className="mt-2">{form.responseCount}</p> 168 207 </div> 169 208 <div> 170 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("dashboard.updated")}</p> 209 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 210 + {t("dashboard.updated")} 211 + </p> 171 212 <p className="mt-2">{formatDate(form.updatedAt, locale)}</p> 172 213 </div> 173 214 </div> 174 - 175 215 </Card> 176 216 ))} 177 217 </section> ··· 182 222 <thead className="bg-[var(--bg-strong)] border-b border-[color:var(--line)] shadow-[inset_0_-1px_0_rgba(23,23,23,0.06)]"> 183 223 <tr className="text-xs uppercase text-[var(--muted)]"> 184 224 <th className="px-5 py-4"> 185 - <button type="button" onClick={() => handleSort("title")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]"> 225 + <button 226 + type="button" 227 + onClick={() => handleSort("title")} 228 + className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]" 229 + > 186 230 {t("dashboard.titleColumn")} 187 231 {renderSortIcon("title")} 188 232 </button> 189 233 </th> 190 234 <th className="px-5 py-4"> 191 - <button type="button" onClick={() => handleSort("status")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]"> 235 + <button 236 + type="button" 237 + onClick={() => handleSort("status")} 238 + className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]" 239 + > 192 240 {t("dashboard.statusColumn")} 193 241 {renderSortIcon("status")} 194 242 </button> 195 243 </th> 196 244 <th className="px-5 py-4"> 197 - <button type="button" onClick={() => handleSort("responseCount")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]"> 245 + <button 246 + type="button" 247 + onClick={() => handleSort("responseCount")} 248 + className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]" 249 + > 198 250 {t("dashboard.responses")} 199 251 {renderSortIcon("responseCount")} 200 252 </button> 201 253 </th> 202 254 <th className="px-5 py-4"> 203 - <button type="button" onClick={() => handleSort("updatedAt")} className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]"> 255 + <button 256 + type="button" 257 + onClick={() => handleSort("updatedAt")} 258 + className="inline-flex items-center gap-2 font-semibold transition hover:text-[var(--ink)]" 259 + > 204 260 {t("dashboard.updated")} 205 261 {renderSortIcon("updatedAt")} 206 262 </button> ··· 212 268 <tr key={form.id} className="align-top"> 213 269 <td className="px-5 py-4"> 214 270 <div className="min-w-[220px]"> 215 - <Link href={`/forms/${form.id}/edit`} className="font-medium text-[var(--ink)] transition hover:text-[var(--accent)]"> 271 + <Link 272 + href={`/forms/${form.id}/edit`} 273 + className="font-medium text-[var(--ink)] transition hover:text-[var(--accent)]" 274 + > 216 275 {form.title} 217 276 </Link> 218 277 </div> ··· 220 279 <td className="px-5 py-4"> 221 280 <FormStatusBadge status={form.status} /> 222 281 </td> 223 - <td className="px-5 py-4 text-[var(--muted)]">{form.responseCount}</td> 224 - <td className="px-5 py-4 text-[var(--muted)]">{formatDate(form.updatedAt, locale)}</td> 282 + <td className="px-5 py-4 text-[var(--muted)]"> 283 + {form.responseCount} 284 + </td> 285 + <td className="px-5 py-4 text-[var(--muted)]"> 286 + {formatDate(form.updatedAt, locale)} 287 + </td> 225 288 </tr> 226 289 ))} 227 290 </tbody>
+13 -3
components/delete-organization-button.tsx
··· 21 21 return ( 22 22 <form id={formId} action={deleteOrganizationAction} className="mt-4"> 23 23 <input type="hidden" name="organizationId" value={organizationId} /> 24 - <input type="hidden" name="returnOrganizationId" value={returnOrganizationId} /> 24 + <input 25 + type="hidden" 26 + name="returnOrganizationId" 27 + value={returnOrganizationId} 28 + /> 25 29 <Button variant="danger" type="button" onClick={() => setOpen(true)}> 26 30 {t("settings.organizations.deleteOrganization")} 27 31 </Button> ··· 29 33 open={open} 30 34 onClose={() => setOpen(false)} 31 35 title={t("settings.organizations.deleteOrganizationTitle")} 32 - description={t("settings.organizations.deleteOrganizationConfirmDescription")} 36 + description={t( 37 + "settings.organizations.deleteOrganizationConfirmDescription", 38 + )} 33 39 confirmLabel={t("ui.delete")} 34 40 cancelLabel={t("ui.cancel")} 35 41 actions={ 36 42 <div className="mt-6 flex flex-wrap justify-end gap-3"> 37 - <Button variant="secondary" type="button" onClick={() => setOpen(false)}> 43 + <Button 44 + variant="secondary" 45 + type="button" 46 + onClick={() => setOpen(false)} 47 + > 38 48 {t("ui.cancel")} 39 49 </Button> 40 50 <Button variant="danger" type="submit" form={formId}>
+17 -3
components/empty-state.tsx
··· 2 2 3 3 import { Card } from "@/components/ui/card"; 4 4 5 - export function EmptyState({ eyebrow, title, description, action }: { eyebrow: string; title: string; description: string; action?: ReactNode }) { 5 + export function EmptyState({ 6 + eyebrow, 7 + title, 8 + description, 9 + action, 10 + }: { 11 + eyebrow: string; 12 + title: string; 13 + description: string; 14 + action?: ReactNode; 15 + }) { 6 16 return ( 7 17 <Card className="px-8 py-8"> 8 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{eyebrow}</p> 18 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 19 + {eyebrow} 20 + </p> 9 21 <h3 className="mt-3 font-display text-2xl text-[var(--ink)]">{title}</h3> 10 - <p className="mt-2 max-w-xl text-sm leading-6 text-[var(--muted)]">{description}</p> 22 + <p className="mt-2 max-w-xl text-sm leading-6 text-[var(--muted)]"> 23 + {description} 24 + </p> 11 25 {action ? <div className="mt-5 flex">{action}</div> : null} 12 26 </Card> 13 27 );
+553 -132
components/form-builder-panels.tsx
··· 8 8 useSensor, 9 9 useSensors, 10 10 } from "@dnd-kit/core"; 11 - import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; 11 + import { 12 + arrayMove, 13 + SortableContext, 14 + useSortable, 15 + verticalListSortingStrategy, 16 + } from "@dnd-kit/sortable"; 12 17 import { CSS } from "@dnd-kit/utilities"; 13 - import { ChevronDown, Copy, ExternalLink, EyeOff, Globe, GripVertical, Link as LinkIcon, LoaderCircle, MessagesSquare, Plus, Save, Settings2, Trash2, Upload, type LucideIcon } from "lucide-react"; 18 + import { 19 + ChevronDown, 20 + Copy, 21 + ExternalLink, 22 + EyeOff, 23 + Globe, 24 + GripVertical, 25 + Link as LinkIcon, 26 + LoaderCircle, 27 + MessagesSquare, 28 + Plus, 29 + Save, 30 + Settings2, 31 + Trash2, 32 + Upload, 33 + type LucideIcon, 34 + } from "lucide-react"; 14 35 import Link from "next/link"; 15 36 import * as React from "react"; 16 37 import type { Dispatch, SetStateAction } from "react"; ··· 20 41 import { Button } from "@/components/ui/button"; 21 42 import { Checkbox } from "@/components/ui/checkbox"; 22 43 import { Input } from "@/components/ui/input"; 23 - import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 44 + import { 45 + Popover, 46 + PopoverContent, 47 + PopoverTrigger, 48 + } from "@/components/ui/popover"; 24 49 import { 25 50 Select, 26 51 SelectContent, ··· 94 119 onCopyShareLink: () => void; 95 120 }) { 96 121 const { t } = useI18n(); 97 - const workspaceLabel = form.workspace.kind === "personal" ? t("workspace.personal") : form.workspace.label; 98 - const [isPublicationMenuOpen, setIsPublicationMenuOpen] = React.useState(false); 122 + const workspaceLabel = 123 + form.workspace.kind === "personal" 124 + ? t("workspace.personal") 125 + : form.workspace.label; 126 + const [isPublicationMenuOpen, setIsPublicationMenuOpen] = 127 + React.useState(false); 99 128 const isPublished = form.status === "PUBLISHED"; 100 129 const isPublishBusy = busy === "publish"; 101 130 ··· 103 132 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 104 133 <div> 105 134 <div className="flex flex-wrap items-center gap-3"> 106 - <h1 className="font-display text-4xl text-[var(--ink)]">{form.title}</h1> 135 + <h1 className="font-display text-4xl text-[var(--ink)]"> 136 + {form.title} 137 + </h1> 107 138 <FormStatusBadge status={form.status} /> 108 139 </div> 109 - <p className="mt-2 text-xs font-medium uppercase text-[var(--muted)]">{workspaceLabel}</p> 140 + <p className="mt-2 text-xs font-medium uppercase text-[var(--muted)]"> 141 + {workspaceLabel} 142 + </p> 110 143 </div> 111 144 <div className="flex flex-wrap items-center gap-3 lg:justify-end"> 112 - <Button variant={settingsSelected ? "default" : "secondary"} onClick={onOpenSettings}> 145 + <Button 146 + variant={settingsSelected ? "default" : "secondary"} 147 + onClick={onOpenSettings} 148 + > 113 149 <Settings2 className="size-4" /> 114 150 {t("builder.settings")} 115 151 </Button> ··· 119 155 {t("builder.responses", { count: form.responseCount })} 120 156 </Button> 121 157 </Link> 122 - <Popover open={isPublicationMenuOpen} onOpenChange={setIsPublicationMenuOpen}> 158 + <Popover 159 + open={isPublicationMenuOpen} 160 + onOpenChange={setIsPublicationMenuOpen} 161 + > 123 162 <PopoverTrigger asChild> 124 - <Button variant={isPublished ? "default" : "secondary"} aria-haspopup="menu" aria-expanded={isPublicationMenuOpen}> 125 - {isPublishBusy ? <LoaderCircle className="size-4 animate-spin" /> : <Globe className="size-4" />} 163 + <Button 164 + variant={isPublished ? "default" : "secondary"} 165 + aria-haspopup="menu" 166 + aria-expanded={isPublicationMenuOpen} 167 + > 168 + {isPublishBusy ? ( 169 + <LoaderCircle className="size-4 animate-spin" /> 170 + ) : ( 171 + <Globe className="size-4" /> 172 + )} 126 173 {t("builder.publicationMenu")} 127 174 <ChevronDown className="size-4" /> 128 175 </Button> ··· 143 190 onPublish(); 144 191 }} 145 192 > 146 - {isPublished ? <EyeOff className="size-4" /> : <Upload className="size-4" />} 193 + {isPublished ? ( 194 + <EyeOff className="size-4" /> 195 + ) : ( 196 + <Upload className="size-4" /> 197 + )} 147 198 {t(isPublished ? "builder.unpublish" : "builder.publish")} 148 199 </button> 149 200 <div className="my-1 h-px bg-[color:var(--line)]" /> ··· 197 248 return ( 198 249 <div className="space-y-8"> 199 250 <div> 200 - <h2 className="font-display text-4xl text-[var(--ink)]">{t("builder.settingsTitle")}</h2> 251 + <h2 className="font-display text-4xl text-[var(--ink)]"> 252 + {t("builder.settingsTitle")} 253 + </h2> 201 254 </div> 202 255 203 256 <div className="grid gap-5"> 204 257 <label className="grid gap-2 text-sm text-[var(--muted)]"> 205 - <span className="font-medium text-[var(--ink)]">{t("builder.titleField")}</span> 206 - <Input value={metadataDraft.title} onChange={(event) => setMetadataDraft((current) => ({ ...current, title: event.target.value }))} /> 258 + <span className="font-medium text-[var(--ink)]"> 259 + {t("builder.titleField")} 260 + </span> 261 + <Input 262 + value={metadataDraft.title} 263 + onChange={(event) => 264 + setMetadataDraft((current) => ({ 265 + ...current, 266 + title: event.target.value, 267 + })) 268 + } 269 + /> 207 270 </label> 208 271 </div> 209 272 210 273 <div className="grid gap-5 border-t border-[color:var(--line)] pt-6"> 211 274 <div> 212 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("builder.publicRoute")}</p> 275 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 276 + {t("builder.publicRoute")} 277 + </p> 213 278 <p className="mt-1 text-xs text-[var(--muted)]"> 214 279 {t("builder.publicRouteHelp")} 215 280 </p> 216 281 </div> 217 282 <div className="grid gap-2 text-sm text-[var(--muted)]"> 218 - <span className="font-medium text-[var(--ink)]">{t("builder.shareSlug")}</span> 283 + <span className="font-medium text-[var(--ink)]"> 284 + {t("builder.shareSlug")} 285 + </span> 219 286 <div className="flex flex-col gap-3 lg:flex-row lg:items-center"> 220 287 <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)]"> 221 - <span className="shrink-0 border-r border-[color:var(--line)] px-4 font-mono text-[13px] text-[var(--muted)]">/f/</span> 288 + <span className="shrink-0 border-r border-[color:var(--line)] px-4 font-mono text-[13px] text-[var(--muted)]"> 289 + /f/ 290 + </span> 222 291 <input 223 292 value={metadataDraft.slug} 224 293 className="h-full w-full min-w-0 bg-transparent px-4 font-mono text-[13px] text-[var(--ink)] outline-none" 225 - onChange={(event) => setMetadataDraft((current) => ({ ...current, slug: event.target.value }))} 294 + onChange={(event) => 295 + setMetadataDraft((current) => ({ 296 + ...current, 297 + slug: event.target.value, 298 + })) 299 + } 226 300 /> 227 301 </div> 228 302 <div className="flex flex-wrap gap-3 lg:shrink-0"> ··· 243 317 244 318 <div className="space-y-5 border-t border-[color:var(--line)] pt-6"> 245 319 <div> 246 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("builder.afterSubmission")}</p> 320 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 321 + {t("builder.afterSubmission")} 322 + </p> 247 323 <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 248 324 {t("builder.afterSubmissionDescription")} 249 325 </p> ··· 251 327 252 328 <div className="grid gap-5"> 253 329 <label className="grid gap-2 text-sm text-[var(--muted)]"> 254 - <span className="font-medium text-[var(--ink)]">{t("builder.completionTitle")}</span> 255 - <Input value={metadataDraft.completionTitle} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionTitle: event.target.value }))} /> 330 + <span className="font-medium text-[var(--ink)]"> 331 + {t("builder.completionTitle")} 332 + </span> 333 + <Input 334 + value={metadataDraft.completionTitle} 335 + onChange={(event) => 336 + setMetadataDraft((current) => ({ 337 + ...current, 338 + completionTitle: event.target.value, 339 + })) 340 + } 341 + /> 256 342 </label> 257 343 <label className="grid gap-2 text-sm text-[var(--muted)]"> 258 - <span className="font-medium text-[var(--ink)]">{t("builder.completionMessage")}</span> 259 - <Textarea value={metadataDraft.completionMessage} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionMessage: event.target.value }))} /> 344 + <span className="font-medium text-[var(--ink)]"> 345 + {t("builder.completionMessage")} 346 + </span> 347 + <Textarea 348 + value={metadataDraft.completionMessage} 349 + onChange={(event) => 350 + setMetadataDraft((current) => ({ 351 + ...current, 352 + completionMessage: event.target.value, 353 + })) 354 + } 355 + /> 260 356 </label> 261 357 <div className="grid gap-5 lg:grid-cols-2"> 262 358 <label className="grid gap-2 text-sm text-[var(--muted)]"> 263 - <span className="font-medium text-[var(--ink)]">{t("builder.followUpLabel")}</span> 264 - <Input value={metadataDraft.completionLinkLabel} placeholder={t("builder.followUpLabelPlaceholder")} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkLabel: event.target.value }))} /> 359 + <span className="font-medium text-[var(--ink)]"> 360 + {t("builder.followUpLabel")} 361 + </span> 362 + <Input 363 + value={metadataDraft.completionLinkLabel} 364 + placeholder={t("builder.followUpLabelPlaceholder")} 365 + onChange={(event) => 366 + setMetadataDraft((current) => ({ 367 + ...current, 368 + completionLinkLabel: event.target.value, 369 + })) 370 + } 371 + /> 265 372 </label> 266 373 <label className="grid gap-2 text-sm text-[var(--muted)]"> 267 - <span className="font-medium text-[var(--ink)]">{t("builder.followUpUrl")}</span> 268 - <Input value={metadataDraft.completionLinkUrl} placeholder={t("builder.followUpUrlPlaceholder")} onChange={(event) => setMetadataDraft((current) => ({ ...current, completionLinkUrl: event.target.value }))} /> 374 + <span className="font-medium text-[var(--ink)]"> 375 + {t("builder.followUpUrl")} 376 + </span> 377 + <Input 378 + value={metadataDraft.completionLinkUrl} 379 + placeholder={t("builder.followUpUrlPlaceholder")} 380 + onChange={(event) => 381 + setMetadataDraft((current) => ({ 382 + ...current, 383 + completionLinkUrl: event.target.value, 384 + })) 385 + } 386 + /> 269 387 </label> 270 388 </div> 271 389 </div> ··· 273 391 274 392 <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 275 393 <Button variant="danger" onClick={deleteForm}> 276 - {busy === "delete-form" ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 394 + {busy === "delete-form" ? ( 395 + <LoaderCircle className="size-4 animate-spin" /> 396 + ) : ( 397 + <Trash2 className="size-4" /> 398 + )} 277 399 {t("builder.deleteForm")} 278 400 </Button> 279 401 <Button onClick={saveMetadata}> 280 - {busy === "metadata" ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 402 + {busy === "metadata" ? ( 403 + <LoaderCircle className="size-4 animate-spin" /> 404 + ) : ( 405 + <Save className="size-4" /> 406 + )} 281 407 {t("builder.saveFormSettings")} 282 408 </Button> 283 409 </div> ··· 299 425 onRemove: (optionId: string) => void; 300 426 }) { 301 427 const { t } = useI18n(); 302 - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: option.id }); 428 + const { attributes, listeners, setNodeRef, transform, transition } = 429 + useSortable({ id: option.id }); 303 430 304 431 return ( 305 432 <div ··· 319 446 > 320 447 <GripVertical className="size-4" /> 321 448 </button> 322 - <Input value={option.value} placeholder={t("builder.option", { number: index + 1 })} onChange={(event) => onChange(option.id, event.target.value)} /> 449 + <Input 450 + value={option.value} 451 + placeholder={t("builder.option", { number: index + 1 })} 452 + onChange={(event) => onChange(option.id, event.target.value)} 453 + /> 323 454 <button 324 455 type="button" 325 456 className="inline-flex size-10 shrink-0 items-center justify-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--muted)] transition hover:bg-[var(--danger-hover)] hover:text-[var(--danger-contrast)] hover:border-[var(--danger-hover)] disabled:opacity-100 disabled:text-[var(--line-strong)]" ··· 347 478 index: number; 348 479 placeholder: string; 349 480 targetOptions: BuilderBlock[]; 350 - onChange: (ruleId: string, patch: Partial<Pick<BranchRuleDraft, "operator" | "value" | "targetBlockId">>) => void; 481 + onChange: ( 482 + ruleId: string, 483 + patch: Partial< 484 + Pick<BranchRuleDraft, "operator" | "value" | "targetBlockId"> 485 + >, 486 + ) => void; 351 487 onRemove: (ruleId: string) => void; 352 488 }) { 353 489 const { t } = useI18n(); 354 - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: rule.id }); 355 - const staleTargetMissing = rule.targetBlockId && !targetOptions.some((targetBlock) => targetBlock.id === rule.targetBlockId); 490 + const { attributes, listeners, setNodeRef, transform, transition } = 491 + useSortable({ id: rule.id }); 492 + const staleTargetMissing = 493 + rule.targetBlockId && 494 + !targetOptions.some((targetBlock) => targetBlock.id === rule.targetBlockId); 356 495 const visibleOperators = getVisibleBranchOperators(block.type); 357 - const supportedOperators = visibleOperators.includes(rule.operator) ? visibleOperators : [rule.operator, ...visibleOperators]; 358 - const showValueInput = branchOperatorNeedsValue(rule.operator) && block.type !== "AGREEMENT"; 359 - const valueInputType = block.type === "NUMBER" ? "number" : block.type === "DATE" ? "date" : "text"; 496 + const supportedOperators = visibleOperators.includes(rule.operator) 497 + ? visibleOperators 498 + : [rule.operator, ...visibleOperators]; 499 + const showValueInput = 500 + branchOperatorNeedsValue(rule.operator) && block.type !== "AGREEMENT"; 501 + const valueInputType = 502 + block.type === "NUMBER" 503 + ? "number" 504 + : block.type === "DATE" 505 + ? "date" 506 + : "text"; 360 507 const discreteOptions = 361 508 block.type === "SINGLE_CHOICE" || block.type === "MULTIPLE_CHOICE" 362 509 ? (block.config as { options: string[] }).options ··· 385 532 </button> 386 533 <div className="grid min-w-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1fr)_minmax(220px,0.9fr)]"> 387 534 <label className="grid gap-2 text-sm text-[var(--muted)]"> 388 - <span className="font-medium text-[var(--ink)]">{t("builder.branchOperator")}</span> 535 + <span className="font-medium text-[var(--ink)]"> 536 + {t("builder.branchOperator")} 537 + </span> 389 538 <Select 390 539 value={rule.operator} 391 540 onValueChange={(value) => ··· 406 555 <SelectContent> 407 556 {supportedOperators.map((operator) => ( 408 557 <SelectItem key={operator} value={operator}> 409 - {block.type === "SINGLE_CHOICE" && (operator === "equals" || operator === "not_equals") 410 - ? t(`builder.branchOperatorLabels.singleChoice.${operator}`) 411 - : block.type === "MULTIPLE_CHOICE" && operator === "contains_any" 412 - ? t("builder.branchOperatorLabels.multipleChoice.contains_any") 413 - : block.type === "AGREEMENT" && (operator === "equals" || operator === "not_equals") 414 - ? t(`builder.branchOperatorLabels.agreement.${operator}`) 415 - : block.type === "DATE" && ["equals", "not_equals", "gt", "gte", "lt", "lte"].includes(operator) 558 + {block.type === "SINGLE_CHOICE" && 559 + (operator === "equals" || operator === "not_equals") 560 + ? t( 561 + `builder.branchOperatorLabels.singleChoice.${operator}`, 562 + ) 563 + : block.type === "MULTIPLE_CHOICE" && 564 + operator === "contains_any" 565 + ? t( 566 + "builder.branchOperatorLabels.multipleChoice.contains_any", 567 + ) 568 + : block.type === "AGREEMENT" && 569 + (operator === "equals" || operator === "not_equals") 570 + ? t( 571 + `builder.branchOperatorLabels.agreement.${operator}`, 572 + ) 573 + : block.type === "DATE" && 574 + [ 575 + "equals", 576 + "not_equals", 577 + "gt", 578 + "gte", 579 + "lt", 580 + "lte", 581 + ].includes(operator) 416 582 ? t(`builder.branchOperatorLabels.date.${operator}`) 417 583 : t(`builder.branchOperators.${operator}`)} 418 584 </SelectItem> ··· 423 589 424 590 {showValueInput ? ( 425 591 <label className="grid gap-2 text-sm text-[var(--muted)]"> 426 - <span className="font-medium text-[var(--ink)]">{t("builder.branchWhenAnswer")}</span> 592 + <span className="font-medium text-[var(--ink)]"> 593 + {t("builder.branchWhenAnswer")} 594 + </span> 427 595 {discreteOptions ? ( 428 - <Select value={rule.value ?? undefined} onValueChange={(value) => onChange(rule.id, { value })}> 596 + <Select 597 + value={rule.value ?? undefined} 598 + onValueChange={(value) => onChange(rule.id, { value })} 599 + > 429 600 <SelectTrigger className="h-10 w-full text-sm font-medium"> 430 601 <SelectValue placeholder={placeholder} /> 431 602 </SelectTrigger> 432 603 <SelectContent> 433 604 {discreteOptions.map((option) => ( 434 605 <SelectItem key={option} value={option}> 435 - {block.type === "AGREEMENT" ? t(`builder.branchAgreementValues.${option}`) : option} 606 + {block.type === "AGREEMENT" 607 + ? t(`builder.branchAgreementValues.${option}`) 608 + : option} 436 609 </SelectItem> 437 610 ))} 438 611 </SelectContent> ··· 442 615 type={valueInputType} 443 616 value={rule.value ?? ""} 444 617 placeholder={placeholder} 445 - onChange={(event) => onChange(rule.id, { value: event.target.value })} 618 + onChange={(event) => 619 + onChange(rule.id, { value: event.target.value }) 620 + } 446 621 /> 447 622 )} 448 623 </label> 449 624 ) : ( 450 625 <div className="grid gap-2 text-sm text-[var(--muted)]"> 451 - <span className="font-medium text-[var(--ink)]">{t("builder.branchWhenAnswer")}</span> 626 + <span className="font-medium text-[var(--ink)]"> 627 + {t("builder.branchWhenAnswer")} 628 + </span> 452 629 <div className="flex h-10 items-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-3 text-sm text-[var(--muted)]"> 453 630 {t("builder.branchNoValueNeeded")} 454 631 </div> ··· 456 633 )} 457 634 458 635 <label className="grid gap-2 text-sm text-[var(--muted)]"> 459 - <span className="font-medium text-[var(--ink)]">{t("builder.branchGoTo")}</span> 460 - <Select value={rule.targetBlockId} onValueChange={(value) => onChange(rule.id, { targetBlockId: value })}> 636 + <span className="font-medium text-[var(--ink)]"> 637 + {t("builder.branchGoTo")} 638 + </span> 639 + <Select 640 + value={rule.targetBlockId} 641 + onValueChange={(value) => 642 + onChange(rule.id, { targetBlockId: value }) 643 + } 644 + > 461 645 <SelectTrigger className="h-10 w-full text-sm font-medium"> 462 646 <SelectValue /> 463 647 </SelectTrigger> 464 648 <SelectContent> 465 649 {staleTargetMissing ? ( 466 - <SelectItem value={rule.targetBlockId}>{t("builder.branchMissingTarget")}</SelectItem> 650 + <SelectItem value={rule.targetBlockId}> 651 + {t("builder.branchMissingTarget")} 652 + </SelectItem> 467 653 ) : null} 468 654 {targetOptions.map((targetBlock) => ( 469 655 <SelectItem key={targetBlock.id} value={targetBlock.id}> ··· 515 701 busy: string | null; 516 702 }) { 517 703 const { t } = useI18n(); 518 - const optionSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 519 - const branchRuleSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 704 + const optionSensors = useSensors( 705 + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), 706 + ); 707 + const branchRuleSensors = useSensors( 708 + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), 709 + ); 520 710 const branchTargetOptions = getBranchTargetBlocks(allBlocks, blockDraft.id); 521 - const blockBranchIssues = branchValidationIssues.filter((issue) => issue.blockId === blockDraft.id); 522 - const blockBranchBlockers = blockBranchIssues.filter((issue) => issue.severity === "blocker"); 523 - const blockBranchWarnings = blockBranchIssues.filter((issue) => issue.severity === "warning"); 711 + const blockBranchIssues = branchValidationIssues.filter( 712 + (issue) => issue.blockId === blockDraft.id, 713 + ); 714 + const blockBranchBlockers = blockBranchIssues.filter( 715 + (issue) => issue.severity === "blocker", 716 + ); 717 + const blockBranchWarnings = blockBranchIssues.filter( 718 + (issue) => issue.severity === "warning", 719 + ); 524 720 const branchRulePlaceholder = getBranchRuleValueHint(blockDraft); 525 - const defaultNextBlockId = "defaultNextBlockId" in blockDraft.config ? blockDraft.config.defaultNextBlockId : null; 721 + const defaultNextBlockId = 722 + "defaultNextBlockId" in blockDraft.config 723 + ? blockDraft.config.defaultNextBlockId 724 + : null; 526 725 const defaultNextSelectValue = defaultNextBlockId ?? "__linear__"; 527 726 const staleDefaultNextMissing = Boolean( 528 - defaultNextBlockId && !branchTargetOptions.some((targetBlock) => targetBlock.id === defaultNextBlockId), 727 + defaultNextBlockId && 728 + !branchTargetOptions.some( 729 + (targetBlock) => targetBlock.id === defaultNextBlockId, 730 + ), 529 731 ); 530 732 531 733 function updateConfig(patch: Record<string, unknown>) { ··· 548 750 } 549 751 550 752 function updateChoiceOption(optionId: string, value: string) { 551 - syncChoiceOptions(choiceOptionsDraft.map((option) => (option.id === optionId ? { ...option, value } : option))); 753 + syncChoiceOptions( 754 + choiceOptionsDraft.map((option) => 755 + option.id === optionId ? { ...option, value } : option, 756 + ), 757 + ); 552 758 } 553 759 554 760 function addChoiceOption() { ··· 556 762 return; 557 763 } 558 764 559 - syncChoiceOptions([...choiceOptionsDraft, { id: crypto.randomUUID(), value: t("builder.option", { number: choiceOptionsDraft.length + 1 }) }]); 765 + syncChoiceOptions([ 766 + ...choiceOptionsDraft, 767 + { 768 + id: crypto.randomUUID(), 769 + value: t("builder.option", { number: choiceOptionsDraft.length + 1 }), 770 + }, 771 + ]); 560 772 } 561 773 562 774 function removeChoiceOption(optionId: string) { ··· 564 776 return; 565 777 } 566 778 567 - syncChoiceOptions(choiceOptionsDraft.filter((option) => option.id !== optionId)); 779 + syncChoiceOptions( 780 + choiceOptionsDraft.filter((option) => option.id !== optionId), 781 + ); 568 782 } 569 783 570 784 function handleChoiceOptionDragEnd(event: DragEndEvent) { ··· 574 788 return; 575 789 } 576 790 577 - const oldIndex = choiceOptionsDraft.findIndex((option) => option.id === active.id); 578 - const newIndex = choiceOptionsDraft.findIndex((option) => option.id === over.id); 791 + const oldIndex = choiceOptionsDraft.findIndex( 792 + (option) => option.id === active.id, 793 + ); 794 + const newIndex = choiceOptionsDraft.findIndex( 795 + (option) => option.id === over.id, 796 + ); 579 797 580 798 if (oldIndex < 0 || newIndex < 0) { 581 799 return; ··· 595 813 }); 596 814 } 597 815 598 - function updateBranchRule(ruleId: string, patch: Partial<Pick<BranchRuleDraft, "operator" | "value" | "targetBlockId">>) { 599 - syncBranchRules(branchRulesDraft.map((rule) => (rule.id === ruleId ? { ...rule, ...patch } : rule))); 816 + function updateBranchRule( 817 + ruleId: string, 818 + patch: Partial< 819 + Pick<BranchRuleDraft, "operator" | "value" | "targetBlockId"> 820 + >, 821 + ) { 822 + syncBranchRules( 823 + branchRulesDraft.map((rule) => 824 + rule.id === ruleId ? { ...rule, ...patch } : rule, 825 + ), 826 + ); 600 827 } 601 828 602 829 function addBranchRule() { ··· 611 838 { 612 839 id: crypto.randomUUID(), 613 840 operator: getVisibleBranchOperators(blockDraft.type)[0] ?? "equals", 614 - value: blockDraft.type === "AGREEMENT" ? AGREEMENT_ANSWER_VALUES.AGREED : null, 841 + value: 842 + blockDraft.type === "AGREEMENT" 843 + ? AGREEMENT_ANSWER_VALUES.AGREED 844 + : null, 615 845 targetBlockId: firstTarget.id, 616 846 }, 617 847 ]); ··· 628 858 return; 629 859 } 630 860 631 - const oldIndex = branchRulesDraft.findIndex((rule) => rule.id === active.id); 861 + const oldIndex = branchRulesDraft.findIndex( 862 + (rule) => rule.id === active.id, 863 + ); 632 864 const newIndex = branchRulesDraft.findIndex((rule) => rule.id === over.id); 633 865 634 866 if (oldIndex < 0 || newIndex < 0) { ··· 642 874 <div className="space-y-8"> 643 875 <div className="flex items-start gap-4"> 644 876 <div className="flex items-center gap-3"> 645 - {SelectedBlockIcon ? <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> : null} 646 - <h2 className="font-display text-4xl text-[var(--ink)]">{t(blockTypeTranslationKeys[blockDraft.type])}</h2> 877 + {SelectedBlockIcon ? ( 878 + <SelectedBlockIcon className="size-6 text-[var(--ink)]" /> 879 + ) : null} 880 + <h2 className="font-display text-4xl text-[var(--ink)]"> 881 + {t(blockTypeTranslationKeys[blockDraft.type])} 882 + </h2> 647 883 </div> 648 884 </div> 649 885 650 886 <div className="grid gap-5"> 651 887 <div className="grid gap-2 text-sm text-[var(--muted)]"> 652 888 {blockDraft.type === "TEXT" ? ( 653 - <span className="font-medium text-[var(--ink)]">{t("builder.headingField")}</span> 889 + <span className="font-medium text-[var(--ink)]"> 890 + {t("builder.headingField")} 891 + </span> 654 892 ) : ( 655 893 <div className="flex flex-wrap items-center justify-between gap-3"> 656 - <span className="font-medium text-[var(--ink)]">{t("builder.prompt")}</span> 894 + <span className="font-medium text-[var(--ink)]"> 895 + {t("builder.prompt")} 896 + </span> 657 897 <label className="inline-flex items-center gap-2 text-sm font-medium text-[var(--ink)]"> 658 898 <Checkbox 659 899 checked={blockDraft.required} 660 - onChange={(event) => setBlockDraft((current) => (current ? { ...current, required: event.target.checked } : current))} 900 + onChange={(event) => 901 + setBlockDraft((current) => 902 + current 903 + ? { ...current, required: event.target.checked } 904 + : current, 905 + ) 906 + } 661 907 /> 662 908 {t("builder.requiredToggle")} 663 909 </label> 664 910 </div> 665 911 )} 666 - <Input value={blockDraft.title} onChange={(event) => setBlockDraft((current) => (current ? { ...current, title: event.target.value } : current))} /> 912 + <Input 913 + value={blockDraft.title} 914 + onChange={(event) => 915 + setBlockDraft((current) => 916 + current ? { ...current, title: event.target.value } : current, 917 + ) 918 + } 919 + /> 667 920 </div> 668 921 669 922 {blockDraft.type !== "TEXT" ? ( 670 923 <label className="grid gap-2 text-sm text-[var(--muted)]"> 671 - <span className="font-medium text-[var(--ink)]">{t("builder.supportText")}</span> 672 - <Textarea value={blockDraft.description} onChange={(event) => setBlockDraft((current) => (current ? { ...current, description: event.target.value } : current))} /> 924 + <span className="font-medium text-[var(--ink)]"> 925 + {t("builder.supportText")} 926 + </span> 927 + <Textarea 928 + value={blockDraft.description} 929 + onChange={(event) => 930 + setBlockDraft((current) => 931 + current 932 + ? { ...current, description: event.target.value } 933 + : current, 934 + ) 935 + } 936 + /> 673 937 </label> 674 938 ) : null} 675 939 676 940 {blockDraft.type === "TEXT" ? ( 677 941 <label className="grid gap-2 text-sm text-[var(--muted)]"> 678 - <span className="font-medium text-[var(--ink)]">{t("builder.bodyCopy")}</span> 942 + <span className="font-medium text-[var(--ink)]"> 943 + {t("builder.bodyCopy")} 944 + </span> 679 945 <Textarea 680 946 value={(blockDraft.config as TextBlockConfig).body} 681 947 onChange={(event) => updateConfig({ body: event.target.value })} ··· 683 949 </label> 684 950 ) : null} 685 951 686 - {(blockDraft.type === "SHORT_TEXT" || blockDraft.type === "LONG_TEXT") && ( 952 + {(blockDraft.type === "SHORT_TEXT" || 953 + blockDraft.type === "LONG_TEXT") && ( 687 954 <> 688 955 <label className="grid gap-2 text-sm text-[var(--muted)]"> 689 - <span className="font-medium text-[var(--ink)]">{t("builder.placeholder")}</span> 956 + <span className="font-medium text-[var(--ink)]"> 957 + {t("builder.placeholder")} 958 + </span> 690 959 <Input 691 - value={blockDraft.type === "SHORT_TEXT" ? (blockDraft.config as ShortTextBlockConfig).placeholder : (blockDraft.config as LongTextBlockConfig).placeholder} 692 - onChange={(event) => updateConfig({ placeholder: event.target.value })} 960 + value={ 961 + blockDraft.type === "SHORT_TEXT" 962 + ? (blockDraft.config as ShortTextBlockConfig).placeholder 963 + : (blockDraft.config as LongTextBlockConfig).placeholder 964 + } 965 + onChange={(event) => 966 + updateConfig({ placeholder: event.target.value }) 967 + } 693 968 /> 694 969 </label> 695 970 696 971 <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)]"> 697 972 <label className="flex items-center gap-3 text-[var(--ink)]"> 698 973 <Checkbox 699 - checked={(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig).validationRegex !== null} 700 - onChange={(event) => updateConfig({ validationRegex: event.target.checked ? "" : null })} 974 + checked={ 975 + ( 976 + blockDraft.config as 977 + | ShortTextBlockConfig 978 + | LongTextBlockConfig 979 + ).validationRegex !== null 980 + } 981 + onChange={(event) => 982 + updateConfig({ 983 + validationRegex: event.target.checked ? "" : null, 984 + }) 985 + } 701 986 /> 702 987 {t("builder.regexValidation")} 703 988 </label> 704 989 705 - {(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig).validationRegex !== null ? ( 990 + {(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig) 991 + .validationRegex !== null ? ( 706 992 <label className="grid gap-2"> 707 - <span className="font-medium text-[var(--ink)]">{t("builder.regexPattern")}</span> 993 + <span className="font-medium text-[var(--ink)]"> 994 + {t("builder.regexPattern")} 995 + </span> 708 996 <Input 709 - value={(blockDraft.config as ShortTextBlockConfig | LongTextBlockConfig).validationRegex ?? ""} 997 + value={ 998 + ( 999 + blockDraft.config as 1000 + | ShortTextBlockConfig 1001 + | LongTextBlockConfig 1002 + ).validationRegex ?? "" 1003 + } 710 1004 placeholder="^[A-Z0-9]+$" 711 - onChange={(event) => updateConfig({ validationRegex: event.target.value })} 1005 + onChange={(event) => 1006 + updateConfig({ validationRegex: event.target.value }) 1007 + } 712 1008 /> 713 1009 </label> 714 1010 ) : null} 715 1011 716 - <p className="text-xs text-[var(--muted)]">{t("builder.regexHelp")}</p> 1012 + <p className="text-xs text-[var(--muted)]"> 1013 + {t("builder.regexHelp")} 1014 + </p> 717 1015 </div> 718 1016 </> 719 1017 )} ··· 721 1019 {blockDraft.type === "NUMBER" ? ( 722 1020 <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)]"> 723 1021 <label className="grid gap-2"> 724 - <span className="font-medium text-[var(--ink)]">{t("builder.placeholder")}</span> 1022 + <span className="font-medium text-[var(--ink)]"> 1023 + {t("builder.placeholder")} 1024 + </span> 725 1025 <Input 726 1026 value={(blockDraft.config as NumberBlockConfig).placeholder} 727 - onChange={(event) => updateConfig({ placeholder: event.target.value })} 1027 + onChange={(event) => 1028 + updateConfig({ placeholder: event.target.value }) 1029 + } 728 1030 /> 729 1031 </label> 730 1032 731 1033 <label className="flex items-center gap-3 text-[var(--ink)]"> 732 1034 <Checkbox 733 1035 checked={(blockDraft.config as NumberBlockConfig).allowFloat} 734 - onChange={(event) => updateConfig({ allowFloat: event.target.checked })} 1036 + onChange={(event) => 1037 + updateConfig({ allowFloat: event.target.checked }) 1038 + } 735 1039 /> 736 1040 {t("builder.allowFloats")} 737 1041 </label> 738 1042 739 1043 <div className="grid gap-5 sm:grid-cols-2"> 740 1044 <label className="grid gap-2"> 741 - <span className="font-medium text-[var(--ink)]">{t("builder.minimumValue")}</span> 1045 + <span className="font-medium text-[var(--ink)]"> 1046 + {t("builder.minimumValue")} 1047 + </span> 742 1048 <Input 743 1049 type="number" 744 - step={(blockDraft.config as NumberBlockConfig).allowFloat ? "any" : "1"} 1050 + step={ 1051 + (blockDraft.config as NumberBlockConfig).allowFloat 1052 + ? "any" 1053 + : "1" 1054 + } 745 1055 value={(blockDraft.config as NumberBlockConfig).min ?? ""} 746 - onChange={(event) => updateConfig({ min: event.target.value === "" ? null : Number(event.target.value) })} 1056 + onChange={(event) => 1057 + updateConfig({ 1058 + min: 1059 + event.target.value === "" 1060 + ? null 1061 + : Number(event.target.value), 1062 + }) 1063 + } 747 1064 /> 748 1065 </label> 749 1066 <label className="grid gap-2"> 750 - <span className="font-medium text-[var(--ink)]">{t("builder.maximumValue")}</span> 1067 + <span className="font-medium text-[var(--ink)]"> 1068 + {t("builder.maximumValue")} 1069 + </span> 751 1070 <Input 752 1071 type="number" 753 - step={(blockDraft.config as NumberBlockConfig).allowFloat ? "any" : "1"} 1072 + step={ 1073 + (blockDraft.config as NumberBlockConfig).allowFloat 1074 + ? "any" 1075 + : "1" 1076 + } 754 1077 value={(blockDraft.config as NumberBlockConfig).max ?? ""} 755 - onChange={(event) => updateConfig({ max: event.target.value === "" ? null : Number(event.target.value) })} 1078 + onChange={(event) => 1079 + updateConfig({ 1080 + max: 1081 + event.target.value === "" 1082 + ? null 1083 + : Number(event.target.value), 1084 + }) 1085 + } 756 1086 /> 757 1087 </label> 758 1088 </div> ··· 761 1091 762 1092 {blockDraft.type === "LINK" ? ( 763 1093 <label className="grid gap-2 text-sm text-[var(--muted)]"> 764 - <span className="font-medium text-[var(--ink)]">{t("builder.placeholder")}</span> 1094 + <span className="font-medium text-[var(--ink)]"> 1095 + {t("builder.placeholder")} 1096 + </span> 765 1097 <Input 766 1098 value={(blockDraft.config as LinkBlockConfig).placeholder} 767 - onChange={(event) => updateConfig({ placeholder: event.target.value })} 1099 + onChange={(event) => 1100 + updateConfig({ placeholder: event.target.value }) 1101 + } 768 1102 /> 769 1103 </label> 770 1104 ) : null} 771 1105 772 1106 {blockDraft.type === "AGREEMENT" ? ( 773 1107 <label className="grid gap-2 text-sm text-[var(--muted)]"> 774 - <span className="font-medium text-[var(--ink)]">{t("builder.checkboxLabel")}</span> 1108 + <span className="font-medium text-[var(--ink)]"> 1109 + {t("builder.checkboxLabel")} 1110 + </span> 775 1111 <Input 776 1112 value={(blockDraft.config as AgreementBlockConfig).label} 777 1113 onChange={(event) => updateConfig({ label: event.target.value })} ··· 779 1115 </label> 780 1116 ) : null} 781 1117 782 - {(blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE") && ( 1118 + {(blockDraft.type === "SINGLE_CHOICE" || 1119 + blockDraft.type === "MULTIPLE_CHOICE") && ( 783 1120 <div className="grid gap-3 text-sm text-[var(--muted)]"> 784 1121 <div className="flex items-center justify-between gap-3"> 785 - <span className="font-medium text-[var(--ink)]">{t("builder.choiceOptions")}</span> 786 - <Button variant="secondary" size="sm" onClick={addChoiceOption} disabled={choiceOptionsDraft.length >= 10}> 1122 + <span className="font-medium text-[var(--ink)]"> 1123 + {t("builder.choiceOptions")} 1124 + </span> 1125 + <Button 1126 + variant="secondary" 1127 + size="sm" 1128 + onClick={addChoiceOption} 1129 + disabled={choiceOptionsDraft.length >= 10} 1130 + > 787 1131 <Plus className="size-4" /> 788 1132 {t("builder.addOption")} 789 1133 </Button> 790 1134 </div> 791 - <DndContext sensors={optionSensors} collisionDetection={closestCenter} onDragEnd={handleChoiceOptionDragEnd}> 792 - <SortableContext items={choiceOptionsDraft.map((option) => option.id)} strategy={verticalListSortingStrategy}> 1135 + <DndContext 1136 + sensors={optionSensors} 1137 + collisionDetection={closestCenter} 1138 + onDragEnd={handleChoiceOptionDragEnd} 1139 + > 1140 + <SortableContext 1141 + items={choiceOptionsDraft.map((option) => option.id)} 1142 + strategy={verticalListSortingStrategy} 1143 + > 793 1144 <div className="grid gap-3"> 794 1145 {choiceOptionsDraft.map((option, index) => ( 795 1146 <SortableChoiceOptionRow ··· 804 1155 </div> 805 1156 </SortableContext> 806 1157 </DndContext> 807 - <p className="text-xs text-[var(--muted)]">{t("builder.optionsHelp")}</p> 1158 + <p className="text-xs text-[var(--muted)]"> 1159 + {t("builder.optionsHelp")} 1160 + </p> 808 1161 </div> 809 1162 )} 810 1163 ··· 812 1165 <div className="grid gap-4 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-4 text-sm text-[var(--muted)]"> 813 1166 <div className="flex items-center justify-between gap-3"> 814 1167 <div> 815 - <p className="font-medium text-[var(--ink)]">{t("builder.branchingTitle")}</p> 816 - <p className="mt-1 text-xs text-[var(--muted)]">{t("builder.branchingDescription")}</p> 1168 + <p className="font-medium text-[var(--ink)]"> 1169 + {t("builder.branchingTitle")} 1170 + </p> 1171 + <p className="mt-1 text-xs text-[var(--muted)]"> 1172 + {t("builder.branchingDescription")} 1173 + </p> 817 1174 </div> 818 - <Button variant="secondary" size="sm" onClick={addBranchRule} disabled={branchTargetOptions.length === 0}> 1175 + <Button 1176 + variant="secondary" 1177 + size="sm" 1178 + onClick={addBranchRule} 1179 + disabled={branchTargetOptions.length === 0} 1180 + > 819 1181 <Plus className="size-4" /> 820 1182 {t("builder.addBranchRule")} 821 1183 </Button> 822 1184 </div> 823 1185 824 1186 {branchRulesDraft.length ? ( 825 - <DndContext sensors={branchRuleSensors} collisionDetection={closestCenter} onDragEnd={handleBranchRuleDragEnd}> 826 - <SortableContext items={branchRulesDraft.map((rule) => rule.id)} strategy={verticalListSortingStrategy}> 1187 + <DndContext 1188 + sensors={branchRuleSensors} 1189 + collisionDetection={closestCenter} 1190 + onDragEnd={handleBranchRuleDragEnd} 1191 + > 1192 + <SortableContext 1193 + items={branchRulesDraft.map((rule) => rule.id)} 1194 + strategy={verticalListSortingStrategy} 1195 + > 827 1196 <div className="grid gap-3"> 828 1197 {branchRulesDraft.map((rule, index) => ( 829 1198 <SortableBranchRuleRow ··· 843 1212 ) : null} 844 1213 845 1214 <label className="grid gap-2 text-sm text-[var(--muted)] sm:max-w-md"> 846 - <span className="font-medium text-[var(--ink)]">{t("builder.branchOtherwise")}</span> 847 - <Select value={defaultNextSelectValue} onValueChange={(value) => updateConfig({ defaultNextBlockId: value === "__linear__" ? null : value })}> 1215 + <span className="font-medium text-[var(--ink)]"> 1216 + {t("builder.branchOtherwise")} 1217 + </span> 1218 + <Select 1219 + value={defaultNextSelectValue} 1220 + onValueChange={(value) => 1221 + updateConfig({ 1222 + defaultNextBlockId: value === "__linear__" ? null : value, 1223 + }) 1224 + } 1225 + > 848 1226 <SelectTrigger className="h-10 w-full text-sm font-medium"> 849 1227 <SelectValue /> 850 1228 </SelectTrigger> 851 1229 <SelectContent> 852 - <SelectItem value="__linear__">{t("builder.branchDefaultLinear")}</SelectItem> 1230 + <SelectItem value="__linear__"> 1231 + {t("builder.branchDefaultLinear")} 1232 + </SelectItem> 853 1233 {staleDefaultNextMissing && defaultNextBlockId ? ( 854 - <SelectItem value={defaultNextBlockId}>{t("builder.branchMissingTarget")}</SelectItem> 1234 + <SelectItem value={defaultNextBlockId}> 1235 + {t("builder.branchMissingTarget")} 1236 + </SelectItem> 855 1237 ) : null} 856 1238 {branchTargetOptions.map((targetBlock) => ( 857 1239 <SelectItem key={targetBlock.id} value={targetBlock.id}> ··· 863 1245 </label> 864 1246 865 1247 {branchTargetOptions.length === 0 ? ( 866 - <p className="text-xs text-[var(--muted)]">{t("builder.branchingNoTargets")}</p> 1248 + <p className="text-xs text-[var(--muted)]"> 1249 + {t("builder.branchingNoTargets")} 1250 + </p> 867 1251 ) : null} 868 - <p className="text-xs text-[var(--muted)]">{t("builder.branchingHelp", { value: branchRulePlaceholder })}</p> 1252 + <p className="text-xs text-[var(--muted)]"> 1253 + {t("builder.branchingHelp", { value: branchRulePlaceholder })} 1254 + </p> 869 1255 870 1256 {blockBranchBlockers.length ? ( 871 1257 <div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-3 py-3 text-sm text-[var(--ink)]"> 872 - <p className="font-medium">{t("builder.branchingBlockersForBlockTitle")}</p> 1258 + <p className="font-medium"> 1259 + {t("builder.branchingBlockersForBlockTitle")} 1260 + </p> 873 1261 <ul className="mt-2 list-disc space-y-1 pl-5 text-[var(--muted)]"> 874 1262 {blockBranchBlockers.map((issue, index) => { 875 - const issueI18n = getBranchValidationIssueI18n(issue, allBlocks); 876 - return <li key={`${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li>; 1263 + const issueI18n = getBranchValidationIssueI18n( 1264 + issue, 1265 + allBlocks, 1266 + ); 1267 + return ( 1268 + <li key={`${issue.code}-${issue.ruleIndex ?? index}`}> 1269 + {t(issueI18n.key, issueI18n.values)} 1270 + </li> 1271 + ); 877 1272 })} 878 1273 </ul> 879 1274 </div> ··· 881 1276 882 1277 {blockBranchWarnings.length ? ( 883 1278 <div className="rounded-xl border border-sky-500/30 bg-sky-500/10 px-3 py-3 text-sm text-[var(--ink)]"> 884 - <p className="font-medium">{t("builder.branchingWarningsForBlockTitle")}</p> 1279 + <p className="font-medium"> 1280 + {t("builder.branchingWarningsForBlockTitle")} 1281 + </p> 885 1282 <ul className="mt-2 list-disc space-y-1 pl-5 text-[var(--muted)]"> 886 1283 {blockBranchWarnings.map((issue, index) => { 887 - const issueI18n = getBranchValidationIssueI18n(issue, allBlocks); 888 - return <li key={`${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li>; 1284 + const issueI18n = getBranchValidationIssueI18n( 1285 + issue, 1286 + allBlocks, 1287 + ); 1288 + return ( 1289 + <li key={`${issue.code}-${issue.ruleIndex ?? index}`}> 1290 + {t(issueI18n.key, issueI18n.values)} 1291 + </li> 1292 + ); 889 1293 })} 890 1294 </ul> 891 1295 </div> 892 1296 ) : null} 893 1297 </div> 894 1298 ) : null} 895 - 896 1299 </div> 897 1300 898 1301 <div className="flex flex-wrap items-center justify-between gap-3 border-t border-[color:var(--line)] pt-6"> 899 1302 <Button variant="danger" onClick={() => deleteBlock(blockDraft.id)}> 900 - {busy === `delete-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Trash2 className="size-4" />} 1303 + {busy === `delete-${blockDraft.id}` ? ( 1304 + <LoaderCircle className="size-4 animate-spin" /> 1305 + ) : ( 1306 + <Trash2 className="size-4" /> 1307 + )} 901 1308 {t("builder.deleteBlock")} 902 1309 </Button> 903 1310 <Button onClick={saveBlock}> 904 - {busy === `block-${blockDraft.id}` ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 1311 + {busy === `block-${blockDraft.id}` ? ( 1312 + <LoaderCircle className="size-4 animate-spin" /> 1313 + ) : ( 1314 + <Save className="size-4" /> 1315 + )} 905 1316 {t("builder.saveBlock")} 906 1317 </Button> 907 1318 </div> ··· 909 1320 ); 910 1321 } 911 1322 912 - export function EmptyEditorState({ onAddShortText }: { onAddShortText: () => void }) { 1323 + export function EmptyEditorState({ 1324 + onAddShortText, 1325 + }: { 1326 + onAddShortText: () => void; 1327 + }) { 913 1328 const { t } = useI18n(); 914 1329 915 1330 return ( 916 1331 <div className="flex min-h-[420px] items-center justify-center border-t border-dashed border-[color:var(--line)] p-10 text-center"> 917 1332 <div> 918 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("builder.nothingSelected")}</p> 919 - <h2 className="mt-4 font-display text-4xl text-[var(--ink)]">{t("builder.nothingSelectedTitle")}</h2> 920 - <p className="mx-auto mt-3 max-w-lg text-sm leading-6 text-[var(--muted)]">{t("builder.nothingSelectedDescription")}</p> 1333 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 1334 + {t("builder.nothingSelected")} 1335 + </p> 1336 + <h2 className="mt-4 font-display text-4xl text-[var(--ink)]"> 1337 + {t("builder.nothingSelectedTitle")} 1338 + </h2> 1339 + <p className="mx-auto mt-3 max-w-lg text-sm leading-6 text-[var(--muted)]"> 1340 + {t("builder.nothingSelectedDescription")} 1341 + </p> 921 1342 <div className="mt-6 flex justify-center"> 922 1343 <Button variant="secondary" onClick={onAddShortText}> 923 1344 <Plus className="size-4" />
+212 -75
components/form-builder.tsx
··· 52 52 type BranchRule, 53 53 type ChoiceBlockConfig, 54 54 } from "@/lib/blocks"; 55 - import { analyzeBranchingGraph, getBranchValidationIssueI18n } from "@/lib/branching"; 55 + import { 56 + analyzeBranchingGraph, 57 + getBranchValidationIssueI18n, 58 + } from "@/lib/branching"; 56 59 import type { BuilderBlock, BuilderForm } from "@/lib/form-types"; 57 60 import { cn } from "@/lib/utils"; 58 61 ··· 105 108 }, 106 109 }); 107 110 108 - const payload = (await response.json().catch(() => ({}))) as { error?: string } & T; 111 + const payload = (await response.json().catch(() => ({}))) as { 112 + error?: string; 113 + } & T; 109 114 110 115 if (!response.ok) { 111 116 throw new Error(payload.error ?? "Request failed."); ··· 124 129 dragHandle: React.ReactNode; 125 130 }) { 126 131 const { t } = useI18n(); 127 - const preview = getBlockPreview(block, t(blockTypeTranslationKeys[block.type])); 132 + const preview = getBlockPreview( 133 + block, 134 + t(blockTypeTranslationKeys[block.type]), 135 + ); 128 136 129 137 return ( 130 138 <> 131 139 {dragHandle} 132 - <button className="min-w-0 flex-1 text-left" type="button" onClick={() => onSelect(block.id)}> 140 + <button 141 + className="min-w-0 flex-1 text-left" 142 + type="button" 143 + onClick={() => onSelect(block.id)} 144 + > 133 145 <div className="min-w-0 flex-1 text-sm font-medium leading-5 text-[var(--ink)]"> 134 146 <span className="block truncate">{preview}</span> 135 147 </div> ··· 148 160 onSelect: (blockId: string) => void; 149 161 }) { 150 162 const { t } = useI18n(); 151 - const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: block.id }); 163 + const { attributes, listeners, setNodeRef, transform, transition } = 164 + useSortable({ id: block.id }); 152 165 const Icon = blockIcons[block.type]; 153 - const preview = getBlockPreview(block, t(blockTypeTranslationKeys[block.type])); 166 + const preview = getBlockPreview( 167 + block, 168 + t(blockTypeTranslationKeys[block.type]), 169 + ); 154 170 155 171 return ( 156 172 <div ··· 228 244 const router = useRouter(); 229 245 const [form, setForm] = useState(initialForm); 230 246 const [selection, setSelection] = useState<Selection>(() => 231 - initialForm.blocks[0] ? { kind: "block", blockId: initialForm.blocks[0].id } : { kind: "form" }, 247 + initialForm.blocks[0] 248 + ? { kind: "block", blockId: initialForm.blocks[0].id } 249 + : { kind: "form" }, 232 250 ); 233 251 const [toasts, setToasts] = useState<ToastData[]>([]); 234 252 const [busy, setBusy] = useState<string | null>(null); ··· 248 266 const blockMenuRef = useRef<HTMLDivElement | null>(null); 249 267 250 268 const selectedBlock = useMemo( 251 - () => (selection.kind === "block" ? form.blocks.find((block) => block.id === selection.blockId) ?? null : null), 269 + () => 270 + selection.kind === "block" 271 + ? (form.blocks.find((block) => block.id === selection.blockId) ?? null) 272 + : null, 252 273 [form.blocks, selection], 253 274 ); 254 275 255 - const [blockDraft, setBlockDraft] = useState<BuilderBlock | null>(selectedBlock); 256 - const [choiceOptionsDraft, setChoiceOptionsDraft] = useState<ChoiceOptionDraft[]>( 257 - selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 258 - ? createChoiceOptionDrafts((selectedBlock.config as ChoiceBlockConfig).options) 276 + const [blockDraft, setBlockDraft] = useState<BuilderBlock | null>( 277 + selectedBlock, 278 + ); 279 + const [choiceOptionsDraft, setChoiceOptionsDraft] = useState< 280 + ChoiceOptionDraft[] 281 + >( 282 + selectedBlock && 283 + (selectedBlock.type === "SINGLE_CHOICE" || 284 + selectedBlock.type === "MULTIPLE_CHOICE") 285 + ? createChoiceOptionDrafts( 286 + (selectedBlock.config as ChoiceBlockConfig).options, 287 + ) 259 288 : [], 260 289 ); 261 290 const [branchRulesDraft, setBranchRulesDraft] = useState<BranchRuleDraft[]>( ··· 265 294 ); 266 295 const SelectedBlockIcon = blockDraft ? blockIcons[blockDraft.type] : null; 267 296 268 - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); 269 - const branchingAnalysis = useMemo(() => analyzeBranchingGraph(form.blocks), [form.blocks]); 297 + const sensors = useSensors( 298 + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), 299 + ); 300 + const branchingAnalysis = useMemo( 301 + () => analyzeBranchingGraph(form.blocks), 302 + [form.blocks], 303 + ); 270 304 271 305 useEffect(() => { 272 306 setMetadataDraft({ ··· 277 311 completionLinkUrl: form.completionLinkUrl ?? "", 278 312 slug: form.slug, 279 313 }); 280 - }, [form.title, form.completionTitle, form.completionMessage, form.completionLinkLabel, form.completionLinkUrl, form.slug]); 314 + }, [ 315 + form.title, 316 + form.completionTitle, 317 + form.completionMessage, 318 + form.completionLinkLabel, 319 + form.completionLinkUrl, 320 + form.slug, 321 + ]); 281 322 282 323 useEffect(() => { 283 324 setBlockDraft(selectedBlock); 284 325 setChoiceOptionsDraft( 285 - selectedBlock && (selectedBlock.type === "SINGLE_CHOICE" || selectedBlock.type === "MULTIPLE_CHOICE") 286 - ? createChoiceOptionDrafts((selectedBlock.config as ChoiceBlockConfig).options) 326 + selectedBlock && 327 + (selectedBlock.type === "SINGLE_CHOICE" || 328 + selectedBlock.type === "MULTIPLE_CHOICE") 329 + ? createChoiceOptionDrafts( 330 + (selectedBlock.config as ChoiceBlockConfig).options, 331 + ) 287 332 : [], 288 333 ); 289 334 setBranchRulesDraft( ··· 312 357 return () => window.removeEventListener("pointerdown", handlePointerDown); 313 358 }, [isBlockMenuOpen]); 314 359 315 - function showToast(message: string, variant: ToastData["variant"] = "success") { 360 + function showToast( 361 + message: string, 362 + variant: ToastData["variant"] = "success", 363 + ) { 316 364 const id = crypto.randomUUID(); 317 365 setToasts((current) => [...current, { id, message, variant }]); 318 366 } ··· 327 375 try { 328 376 await runner(); 329 377 } catch (caughtError) { 330 - const nextError = caughtError instanceof Error ? caughtError.message : t("builder.toasts.genericError"); 378 + const nextError = 379 + caughtError instanceof Error 380 + ? caughtError.message 381 + : t("builder.toasts.genericError"); 331 382 showToast(nextError, "error"); 332 383 } finally { 333 384 setBusy(null); ··· 336 387 337 388 async function saveMetadata() { 338 389 await withTask("metadata", async () => { 339 - const payload = await fetchJson<{ form: BuilderForm }>(`/api/forms/${form.id}`, { 340 - method: "PATCH", 341 - body: JSON.stringify(metadataDraft), 342 - }); 390 + const payload = await fetchJson<{ form: BuilderForm }>( 391 + `/api/forms/${form.id}`, 392 + { 393 + method: "PATCH", 394 + body: JSON.stringify(metadataDraft), 395 + }, 396 + ); 343 397 344 398 setForm(payload.form); 345 399 showToast(t("builder.toasts.savedFormSettings")); ··· 348 402 349 403 async function addBlock(type: BlockType) { 350 404 await withTask(`add-${type}`, async () => { 351 - const payload = await fetchJson<{ block: BuilderBlock }>(`/api/forms/${form.id}/blocks`, { 352 - method: "POST", 353 - body: JSON.stringify({ type }), 354 - }); 405 + const payload = await fetchJson<{ block: BuilderBlock }>( 406 + `/api/forms/${form.id}/blocks`, 407 + { 408 + method: "POST", 409 + body: JSON.stringify({ type }), 410 + }, 411 + ); 355 412 356 413 setForm((current) => ({ 357 414 ...current, ··· 359 416 })); 360 417 setSelection({ kind: "block", blockId: payload.block.id }); 361 418 setIsBlockMenuOpen(false); 362 - showToast(t("builder.toasts.addedBlock", { type: t(blockTypeTranslationKeys[type]) })); 419 + showToast( 420 + t("builder.toasts.addedBlock", { 421 + type: t(blockTypeTranslationKeys[type]), 422 + }), 423 + ); 363 424 }); 364 425 } 365 426 ··· 374 435 value: rule.value, 375 436 targetBlockId: rule.targetBlockId, 376 437 })); 377 - const payload = await fetchJson<{ block: BuilderBlock }>(`/api/forms/${form.id}/blocks/${blockDraft.id}`, { 378 - method: "PATCH", 379 - body: JSON.stringify({ 380 - title: blockDraft.title, 381 - description: blockDraft.description, 382 - required: blockDraft.required, 383 - config: 384 - blockDraft.type === "SINGLE_CHOICE" || blockDraft.type === "MULTIPLE_CHOICE" 385 - ? { 386 - ...blockDraft.config, 387 - options: choiceOptionsDraft.map((option) => option.value), 388 - branchRules: serializedBranchRules, 389 - } 390 - : isQuestionBlock(blockDraft.type) 438 + const payload = await fetchJson<{ block: BuilderBlock }>( 439 + `/api/forms/${form.id}/blocks/${blockDraft.id}`, 440 + { 441 + method: "PATCH", 442 + body: JSON.stringify({ 443 + title: blockDraft.title, 444 + description: blockDraft.description, 445 + required: blockDraft.required, 446 + config: 447 + blockDraft.type === "SINGLE_CHOICE" || 448 + blockDraft.type === "MULTIPLE_CHOICE" 391 449 ? { 392 450 ...blockDraft.config, 451 + options: choiceOptionsDraft.map((option) => option.value), 393 452 branchRules: serializedBranchRules, 394 453 } 395 - : blockDraft.config, 396 - }), 397 - }); 454 + : isQuestionBlock(blockDraft.type) 455 + ? { 456 + ...blockDraft.config, 457 + branchRules: serializedBranchRules, 458 + } 459 + : blockDraft.config, 460 + }), 461 + }, 462 + ); 398 463 399 464 setForm((current) => ({ 400 465 ...current, 401 - blocks: current.blocks.map((block) => (block.id === payload.block.id ? payload.block : block)), 466 + blocks: current.blocks.map((block) => 467 + block.id === payload.block.id ? payload.block : block, 468 + ), 402 469 })); 403 470 showToast(t("builder.toasts.savedBlock")); 404 471 }); ··· 426 493 427 494 async function setPublished(published: boolean) { 428 495 await withTask("publish", async () => { 429 - const payload = await fetchJson<{ form: BuilderForm }>(`/api/forms/${form.id}/publish`, { 430 - method: "POST", 431 - body: JSON.stringify({ published }), 432 - }); 496 + const payload = await fetchJson<{ form: BuilderForm }>( 497 + `/api/forms/${form.id}/publish`, 498 + { 499 + method: "POST", 500 + body: JSON.stringify({ published }), 501 + }, 502 + ); 433 503 434 504 setForm(payload.form); 435 - showToast(payload.form.status === "PUBLISHED" ? t("builder.toasts.formPublished") : t("builder.toasts.formDrafted")); 505 + showToast( 506 + payload.form.status === "PUBLISHED" 507 + ? t("builder.toasts.formPublished") 508 + : t("builder.toasts.formDrafted"), 509 + ); 436 510 router.refresh(); 437 511 }); 438 512 } ··· 474 548 return; 475 549 } 476 550 477 - const reordered = arrayMove(previous, oldIndex, newIndex).map((block, index) => ({ ...block, position: index })); 551 + const reordered = arrayMove(previous, oldIndex, newIndex).map( 552 + (block, index) => ({ ...block, position: index }), 553 + ); 478 554 setForm((current) => ({ ...current, blocks: reordered })); 479 555 480 556 try { 481 - const payload = await fetchJson<{ form: BuilderForm }>(`/api/forms/${form.id}/blocks/reorder`, { 482 - method: "POST", 483 - body: JSON.stringify({ blockIds: reordered.map((block) => block.id) }), 484 - }); 557 + const payload = await fetchJson<{ form: BuilderForm }>( 558 + `/api/forms/${form.id}/blocks/reorder`, 559 + { 560 + method: "POST", 561 + body: JSON.stringify({ 562 + blockIds: reordered.map((block) => block.id), 563 + }), 564 + }, 565 + ); 485 566 486 567 setForm(payload.form); 487 568 showToast(t("builder.toasts.updatedBlockOrder")); 488 569 } catch (caughtError) { 489 570 setForm((current) => ({ ...current, blocks: previous })); 490 - showToast(caughtError instanceof Error ? caughtError.message : t("builder.toasts.reorderError"), "error"); 571 + showToast( 572 + caughtError instanceof Error 573 + ? caughtError.message 574 + : t("builder.toasts.reorderError"), 575 + "error", 576 + ); 491 577 } 492 578 } 493 579 ··· 520 606 521 607 {branchingAnalysis.blockers.length ? ( 522 608 <div className="rounded-[18px] border border-amber-500/30 bg-amber-500/10 px-4 py-4 text-sm text-[var(--ink)]"> 523 - <p className="font-medium">{t("builder.branchingBlockersTitle", { count: branchingAnalysis.blockers.length })}</p> 524 - <p className="mt-1 text-[var(--muted)]">{t("builder.branchingBlockersDescription")}</p> 609 + <p className="font-medium"> 610 + {t("builder.branchingBlockersTitle", { 611 + count: branchingAnalysis.blockers.length, 612 + })} 613 + </p> 614 + <p className="mt-1 text-[var(--muted)]"> 615 + {t("builder.branchingBlockersDescription")} 616 + </p> 525 617 <ul className="mt-3 list-disc space-y-1 pl-5 text-[var(--muted)]"> 526 618 {branchingAnalysis.blockers.slice(0, 5).map((issue, index) => { 527 - const issueI18n = getBranchValidationIssueI18n(issue, form.blocks); 619 + const issueI18n = getBranchValidationIssueI18n( 620 + issue, 621 + form.blocks, 622 + ); 528 623 return ( 529 - <li key={`${issue.blockId}-${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li> 624 + <li 625 + key={`${issue.blockId}-${issue.code}-${issue.ruleIndex ?? index}`} 626 + > 627 + {t(issueI18n.key, issueI18n.values)} 628 + </li> 530 629 ); 531 630 })} 532 631 </ul> ··· 535 634 536 635 {branchingAnalysis.warnings.length ? ( 537 636 <div className="rounded-[18px] border border-sky-500/30 bg-sky-500/10 px-4 py-4 text-sm text-[var(--ink)]"> 538 - <p className="font-medium">{t("builder.branchingWarningsTitle", { count: branchingAnalysis.warnings.length })}</p> 539 - <p className="mt-1 text-[var(--muted)]">{t("builder.branchingWarningsDescription")}</p> 637 + <p className="font-medium"> 638 + {t("builder.branchingWarningsTitle", { 639 + count: branchingAnalysis.warnings.length, 640 + })} 641 + </p> 642 + <p className="mt-1 text-[var(--muted)]"> 643 + {t("builder.branchingWarningsDescription")} 644 + </p> 540 645 <ul className="mt-3 list-disc space-y-1 pl-5 text-[var(--muted)]"> 541 646 {branchingAnalysis.warnings.slice(0, 5).map((issue, index) => { 542 - const issueI18n = getBranchValidationIssueI18n(issue, form.blocks); 647 + const issueI18n = getBranchValidationIssueI18n( 648 + issue, 649 + form.blocks, 650 + ); 543 651 return ( 544 - <li key={`${issue.blockId}-${issue.code}-${issue.ruleIndex ?? index}`}>{t(issueI18n.key, issueI18n.values)}</li> 652 + <li 653 + key={`${issue.blockId}-${issue.code}-${issue.ruleIndex ?? index}`} 654 + > 655 + {t(issueI18n.key, issueI18n.values)} 656 + </li> 545 657 ); 546 658 })} 547 659 </ul> ··· 551 663 <div className="grid gap-8 lg:min-h-[calc(100vh-14rem)] lg:grid-cols-[300px_minmax(0,1fr)]"> 552 664 <aside className="border-b border-[color:var(--line)] pb-6 lg:sticky lg:top-8 lg:self-start lg:max-h-[calc(100vh-8rem)] lg:border-b-0 lg:border-r lg:pb-0 lg:pr-6"> 553 665 <div className="flex items-center justify-between gap-3"> 554 - <h2 className="font-display text-3xl text-[var(--ink)]">{t("builder.blocks")}</h2> 666 + <h2 className="font-display text-3xl text-[var(--ink)]"> 667 + {t("builder.blocks")} 668 + </h2> 555 669 <div className="relative" ref={blockMenuRef}> 556 670 <Button 557 671 size="sm" ··· 575 689 className="flex items-center gap-2 rounded-[12px] px-3 py-2 text-left text-sm font-medium text-[var(--ink)] transition hover:bg-[var(--surface)]" 576 690 onClick={() => addBlock(type)} 577 691 > 578 - {busy === `add-${type}` ? <LoaderCircle className="size-4 animate-spin" /> : <Icon className="size-4" />} 692 + {busy === `add-${type}` ? ( 693 + <LoaderCircle className="size-4 animate-spin" /> 694 + ) : ( 695 + <Icon className="size-4" /> 696 + )} 579 697 {t(blockTypeTranslationKeys[type])} 580 698 </button> 581 699 ); ··· 589 707 <div className="mt-5 lg:max-h-[calc(100vh-8rem)] lg:overflow-y-auto lg:pr-2"> 590 708 <div className="space-y-1.5"> 591 709 {isDndReady ? ( 592 - <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> 593 - <SortableContext items={form.blocks.map((block) => block.id)} strategy={verticalListSortingStrategy}> 710 + <DndContext 711 + sensors={sensors} 712 + collisionDetection={closestCenter} 713 + onDragEnd={handleDragEnd} 714 + > 715 + <SortableContext 716 + items={form.blocks.map((block) => block.id)} 717 + strategy={verticalListSortingStrategy} 718 + > 594 719 {form.blocks.map((block) => ( 595 720 <SortableBlockRow 596 721 key={block.id} 597 722 block={block} 598 - selected={selection.kind === "block" && selection.blockId === block.id} 599 - onSelect={(blockId) => setSelection({ kind: "block", blockId })} 723 + selected={ 724 + selection.kind === "block" && 725 + selection.blockId === block.id 726 + } 727 + onSelect={(blockId) => 728 + setSelection({ kind: "block", blockId }) 729 + } 600 730 /> 601 731 ))} 602 732 </SortableContext> ··· 606 736 <StaticBlockRow 607 737 key={block.id} 608 738 block={block} 609 - selected={selection.kind === "block" && selection.blockId === block.id} 610 - onSelect={(blockId) => setSelection({ kind: "block", blockId })} 739 + selected={ 740 + selection.kind === "block" && 741 + selection.blockId === block.id 742 + } 743 + onSelect={(blockId) => 744 + setSelection({ kind: "block", blockId }) 745 + } 611 746 /> 612 747 )) 613 748 )} 614 749 </div> 615 750 </div> 616 - 617 751 </aside> 618 752 619 753 <section className="min-w-0 border-t border-[color:var(--line)] pt-6 lg:min-h-full lg:border-t-0 lg:pt-0"> ··· 634 768 selectedBlockIcon={SelectedBlockIcon} 635 769 choiceOptionsDraft={choiceOptionsDraft} 636 770 branchRulesDraft={branchRulesDraft} 637 - branchValidationIssues={[...branchingAnalysis.blockers, ...branchingAnalysis.warnings]} 771 + branchValidationIssues={[ 772 + ...branchingAnalysis.blockers, 773 + ...branchingAnalysis.warnings, 774 + ]} 638 775 setChoiceOptionsDraft={setChoiceOptionsDraft} 639 776 setBranchRulesDraft={setBranchRulesDraft} 640 777 setBlockDraft={setBlockDraft}
+9 -2
components/form-status-badge.tsx
··· 4 4 import { Badge } from "@/components/ui/badge"; 5 5 import { cn } from "@/lib/utils"; 6 6 7 - export function FormStatusBadge({ status, className }: { status: FormStatus; className?: string }) { 7 + export function FormStatusBadge({ 8 + status, 9 + className, 10 + }: { 11 + status: FormStatus; 12 + className?: string; 13 + }) { 8 14 const { t } = useI18n(); 9 15 10 16 return ( 11 17 <Badge 12 18 className={cn( 13 19 "rounded-full", 14 - status === "PUBLISHED" && "bg-[var(--accent-soft)] text-[var(--accent-ink)]", 20 + status === "PUBLISHED" && 21 + "bg-[var(--accent-soft)] text-[var(--accent-ink)]", 15 22 className, 16 23 )} 17 24 >
+6 -1
components/i18n-provider.tsx
··· 22 22 children: React.ReactNode; 23 23 }) { 24 24 const value = useMemo( 25 - () => ({ locale, messages, t: (key: string, values?: Record<string, string | number>) => translate(messages, key, values) }), 25 + () => ({ 26 + locale, 27 + messages, 28 + t: (key: string, values?: Record<string, string | number>) => 29 + translate(messages, key, values), 30 + }), 26 31 [locale, messages], 27 32 ); 28 33
+293 -149
components/organization-settings-panel.tsx
··· 15 15 import { Badge } from "@/components/ui/badge"; 16 16 import { Button } from "@/components/ui/button"; 17 17 import { Input } from "@/components/ui/input"; 18 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 18 + import { 19 + Select, 20 + SelectContent, 21 + SelectItem, 22 + SelectTrigger, 23 + SelectValue, 24 + } from "@/components/ui/select"; 19 25 import { ToastViewport, type ToastData } from "@/components/ui/toast"; 20 26 import { useI18n } from "@/components/i18n-provider"; 21 27 import { UserAvatar } from "@/components/user-avatar"; ··· 34 40 const pathname = usePathname(); 35 41 const searchParams = useSearchParams(); 36 42 const { locale, t } = useI18n(); 37 - const [selectedOrganizationId, setSelectedOrganizationId] = useState<string | null>(initialOrganizationId ?? organizations[0]?.id ?? null); 43 + const [selectedOrganizationId, setSelectedOrganizationId] = useState< 44 + string | null 45 + >(initialOrganizationId ?? organizations[0]?.id ?? null); 38 46 const [toasts, setToasts] = useState<ToastData[]>([]); 39 47 const selectedOrganization = useMemo( 40 - () => organizations.find((organization) => organization.id === selectedOrganizationId) ?? organizations[0] ?? null, 48 + () => 49 + organizations.find( 50 + (organization) => organization.id === selectedOrganizationId, 51 + ) ?? 52 + organizations[0] ?? 53 + null, 41 54 [organizations, selectedOrganizationId], 42 55 ); 43 56 44 - function showToast(message: string, variant: ToastData["variant"] = "success") { 57 + function showToast( 58 + message: string, 59 + variant: ToastData["variant"] = "success", 60 + ) { 45 61 const id = crypto.randomUUID(); 46 62 setToasts((current) => [...current, { id, message, variant }]); 47 63 } ··· 65 81 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 66 82 <div> 67 83 <div> 68 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("settings.organizations.title")}</h1> 84 + <h1 className="font-display text-4xl text-[var(--ink)]"> 85 + {t("settings.organizations.title")} 86 + </h1> 69 87 <p className="mt-3 max-w-3xl text-sm leading-6 text-[var(--muted)]"> 70 88 {t("settings.organizations.description")} 71 89 </p> 72 90 </div> 73 91 74 - <div className="mt-8 border-t border-[color:var(--line)] pt-8"> 75 - <form action={createOrganizationAction} className="flex flex-col gap-3 lg:flex-row lg:items-end"> 76 - <input type="hidden" name="returnOrganizationId" value={selectedOrganizationId ?? ""} /> 77 - <label className="grid flex-1 gap-2 text-sm text-[var(--muted)]"> 78 - <span className="font-medium text-[var(--ink)]">{t("settings.organizations.newOrganizationName")}</span> 79 - <Input name="name" placeholder={t("settings.organizations.newOrganizationPlaceholder")} /> 80 - </label> 81 - <Button type="submit">{t("settings.organizations.createOrganization")}</Button> 82 - </form> 83 - </div> 92 + <div className="mt-8 border-t border-[color:var(--line)] pt-8"> 93 + <form 94 + action={createOrganizationAction} 95 + className="flex flex-col gap-3 lg:flex-row lg:items-end" 96 + > 97 + <input 98 + type="hidden" 99 + name="returnOrganizationId" 100 + value={selectedOrganizationId ?? ""} 101 + /> 102 + <label className="grid flex-1 gap-2 text-sm text-[var(--muted)]"> 103 + <span className="font-medium text-[var(--ink)]"> 104 + {t("settings.organizations.newOrganizationName")} 105 + </span> 106 + <Input 107 + name="name" 108 + placeholder={t( 109 + "settings.organizations.newOrganizationPlaceholder", 110 + )} 111 + /> 112 + </label> 113 + <Button type="submit"> 114 + {t("settings.organizations.createOrganization")} 115 + </Button> 116 + </form> 117 + </div> 84 118 85 - <div className="mt-8 space-y-5"> 86 - {organizations.length === 0 ? ( 87 - <div className="border-t border-[color:var(--line)] py-6 text-sm text-[var(--muted)]"> 88 - {t("settings.organizations.noneYet")} 89 - </div> 90 - ) : null} 119 + <div className="mt-8 space-y-5"> 120 + {organizations.length === 0 ? ( 121 + <div className="border-t border-[color:var(--line)] py-6 text-sm text-[var(--muted)]"> 122 + {t("settings.organizations.noneYet")} 123 + </div> 124 + ) : null} 91 125 92 - {selectedOrganization ? ( 93 - <> 94 - <div className="max-w-md border-t border-[color:var(--line)] pt-8"> 95 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("settings.organizations.organizationLabel")}</p> 96 - <p className="mt-2 text-sm leading-6 text-[var(--muted)]">{t("settings.organizations.selectedOrgHelp")}</p> 97 - <div className="mt-3"> 98 - <Select 99 - value={selectedOrganization.id} 100 - onValueChange={(value) => { 101 - setSelectedOrganizationId(value); 126 + {selectedOrganization ? ( 127 + <> 128 + <div className="max-w-md border-t border-[color:var(--line)] pt-8"> 129 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 130 + {t("settings.organizations.organizationLabel")} 131 + </p> 132 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 133 + {t("settings.organizations.selectedOrgHelp")} 134 + </p> 135 + <div className="mt-3"> 136 + <Select 137 + value={selectedOrganization.id} 138 + onValueChange={(value) => { 139 + setSelectedOrganizationId(value); 102 140 103 - const nextParams = new URLSearchParams(searchParams.toString()); 104 - nextParams.set("organization", value); 105 - router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }); 106 - }} 107 - > 108 - <SelectTrigger className="min-w-[240px]"> 109 - <SelectValue /> 110 - </SelectTrigger> 111 - <SelectContent> 112 - {organizations.map((organization) => ( 113 - <SelectItem key={organization.id} value={organization.id}> 114 - {organization.name} 115 - {organization.isOwner ? ` · ${t("settings.organizations.owner")}` : ""} 116 - </SelectItem> 117 - ))} 118 - </SelectContent> 119 - </Select> 120 - </div> 121 - </div> 122 - 123 - <div className="border-t border-[color:var(--line)] pt-8"> 124 - <div className="border-b border-[color:var(--line)] pb-5"> 125 - <div className="flex flex-wrap items-center gap-3"> 126 - <h2 className="font-display text-3xl text-[var(--ink)]">{selectedOrganization.name}</h2> 127 - <Badge className="rounded-full">{selectedOrganization.isOwner ? t("settings.organizations.owner") : t("settings.organizations.member")}</Badge> 141 + const nextParams = new URLSearchParams( 142 + searchParams.toString(), 143 + ); 144 + nextParams.set("organization", value); 145 + router.replace(`${pathname}?${nextParams.toString()}`, { 146 + scroll: false, 147 + }); 148 + }} 149 + > 150 + <SelectTrigger className="min-w-[240px]"> 151 + <SelectValue /> 152 + </SelectTrigger> 153 + <SelectContent> 154 + {organizations.map((organization) => ( 155 + <SelectItem 156 + key={organization.id} 157 + value={organization.id} 158 + > 159 + {organization.name} 160 + {organization.isOwner 161 + ? ` · ${t("settings.organizations.owner")}` 162 + : ""} 163 + </SelectItem> 164 + ))} 165 + </SelectContent> 166 + </Select> 128 167 </div> 129 - 130 - {selectedOrganization.isOwner ? ( 131 - <form key={selectedOrganization.id} action={renameOrganizationAction} className="mt-5 flex max-w-xl flex-col gap-3 sm:flex-row sm:items-center"> 132 - <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 133 - <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 134 - <div className="flex-1"> 135 - <Input name="name" defaultValue={selectedOrganization.name} /> 136 - </div> 137 - <Button type="submit" variant="secondary"> 138 - {t("settings.organizations.renameOrganization")} 139 - </Button> 140 - </form> 141 - ) : null} 142 168 </div> 143 169 144 - <div className="mt-6 space-y-8"> 145 - <div> 146 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("settings.organizations.members")}</p> 147 - <div className="mt-4 space-y-4"> 148 - {selectedOrganization.members.map((member) => ( 149 - <div key={member.userId} className="flex flex-col gap-3 py-1 sm:flex-row sm:items-center sm:justify-between"> 150 - <div className="flex min-w-0 items-center gap-3"> 151 - <UserAvatar user={member} className="size-9" initialsClassName="text-[11px]" /> 152 - <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> 153 - <p className="font-medium text-[var(--ink)]">{getUserDisplayName(member)}</p> 154 - {member.email ? <p className="text-sm text-[var(--muted)]">{member.email}</p> : null} 155 - <Badge className="rounded-full">{member.role === "OWNER" ? t("settings.organizations.owner") : t("settings.organizations.member")}</Badge> 156 - </div> 157 - </div> 158 - {selectedOrganization.isOwner && member.role !== "OWNER" ? ( 159 - <form action={removeOrganizationMemberAction}> 160 - <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 161 - <input type="hidden" name="memberUserId" value={member.userId} /> 162 - <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 163 - <Button variant="secondary" size="sm" type="submit"> 164 - {t("settings.organizations.removeMember")} 165 - </Button> 166 - </form> 167 - ) : null} 168 - </div> 169 - ))} 170 + <div className="border-t border-[color:var(--line)] pt-8"> 171 + <div className="border-b border-[color:var(--line)] pb-5"> 172 + <div className="flex flex-wrap items-center gap-3"> 173 + <h2 className="font-display text-3xl text-[var(--ink)]"> 174 + {selectedOrganization.name} 175 + </h2> 176 + <Badge className="rounded-full"> 177 + {selectedOrganization.isOwner 178 + ? t("settings.organizations.owner") 179 + : t("settings.organizations.member")} 180 + </Badge> 170 181 </div> 171 - </div> 172 182 173 - <div className="border-t border-[color:var(--line)] pt-8"> 174 - <div className="flex items-center justify-between gap-3"> 175 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("settings.organizations.inviteLinks")}</p> 176 - {selectedOrganization.isOwner ? ( 177 - <form action={createOrganizationInviteLinkAction}> 178 - <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 179 - <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 180 - <Button variant="secondary" size="sm" type="submit"> 181 - <Link2 className="size-4" /> 182 - {t("settings.organizations.newInviteLink")} 183 - </Button> 184 - </form> 185 - ) : null} 186 - </div> 187 - <div className="mt-4 space-y-4"> 188 - {selectedOrganization.inviteLinks.length === 0 ? ( 189 - <div className="py-4 text-sm text-[var(--muted)]"> 190 - {t("settings.organizations.noInviteLinks")} 183 + {selectedOrganization.isOwner ? ( 184 + <form 185 + key={selectedOrganization.id} 186 + action={renameOrganizationAction} 187 + className="mt-5 flex max-w-xl flex-col gap-3 sm:flex-row sm:items-center" 188 + > 189 + <input 190 + type="hidden" 191 + name="organizationId" 192 + value={selectedOrganization.id} 193 + /> 194 + <input 195 + type="hidden" 196 + name="returnOrganizationId" 197 + value={selectedOrganization.id} 198 + /> 199 + <div className="flex-1"> 200 + <Input 201 + name="name" 202 + defaultValue={selectedOrganization.name} 203 + /> 191 204 </div> 192 - ) : null} 193 - {selectedOrganization.inviteLinks.map((inviteLink) => { 194 - return ( 195 - <div key={inviteLink.id} className="flex flex-col gap-3 py-1 sm:flex-row sm:items-center sm:justify-between"> 196 - <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> 197 - <button 198 - type="button" 199 - onClick={() => copyInviteLink(inviteLink.token)} 200 - className="truncate font-mono text-[13px] text-[var(--ink)] transition hover:text-[var(--accent)]" 201 - > 202 - {`/join/${inviteLink.token}`} 203 - </button> 204 - <p className="text-xs text-[var(--muted)]">{t("settings.organizations.inviteCreated", { date: formatDate(inviteLink.createdAt, locale) })}</p> 205 + <Button type="submit" variant="secondary"> 206 + {t("settings.organizations.renameOrganization")} 207 + </Button> 208 + </form> 209 + ) : null} 210 + </div> 211 + 212 + <div className="mt-6 space-y-8"> 213 + <div> 214 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 215 + {t("settings.organizations.members")} 216 + </p> 217 + <div className="mt-4 space-y-4"> 218 + {selectedOrganization.members.map((member) => ( 219 + <div 220 + key={member.userId} 221 + className="flex flex-col gap-3 py-1 sm:flex-row sm:items-center sm:justify-between" 222 + > 223 + <div className="flex min-w-0 items-center gap-3"> 224 + <UserAvatar 225 + user={member} 226 + className="size-9" 227 + initialsClassName="text-[11px]" 228 + /> 229 + <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> 230 + <p className="font-medium text-[var(--ink)]"> 231 + {getUserDisplayName(member)} 232 + </p> 233 + {member.email ? ( 234 + <p className="text-sm text-[var(--muted)]"> 235 + {member.email} 236 + </p> 237 + ) : null} 238 + <Badge className="rounded-full"> 239 + {member.role === "OWNER" 240 + ? t("settings.organizations.owner") 241 + : t("settings.organizations.member")} 242 + </Badge> 243 + </div> 205 244 </div> 206 - {selectedOrganization.isOwner ? ( 207 - <form action={revokeOrganizationInviteLinkAction}> 208 - <input type="hidden" name="organizationId" value={selectedOrganization.id} /> 209 - <input type="hidden" name="inviteLinkId" value={inviteLink.id} /> 210 - <input type="hidden" name="returnOrganizationId" value={selectedOrganization.id} /> 211 - <Button variant="secondary" size="sm" type="submit"> 212 - {t("settings.organizations.revokeInvite")} 245 + {selectedOrganization.isOwner && 246 + member.role !== "OWNER" ? ( 247 + <form action={removeOrganizationMemberAction}> 248 + <input 249 + type="hidden" 250 + name="organizationId" 251 + value={selectedOrganization.id} 252 + /> 253 + <input 254 + type="hidden" 255 + name="memberUserId" 256 + value={member.userId} 257 + /> 258 + <input 259 + type="hidden" 260 + name="returnOrganizationId" 261 + value={selectedOrganization.id} 262 + /> 263 + <Button 264 + variant="secondary" 265 + size="sm" 266 + type="submit" 267 + > 268 + {t("settings.organizations.removeMember")} 213 269 </Button> 214 270 </form> 215 271 ) : null} 216 272 </div> 217 - ); 218 - })} 273 + ))} 274 + </div> 219 275 </div> 220 - </div> 221 276 222 - {selectedOrganization.isOwner ? ( 223 277 <div className="border-t border-[color:var(--line)] pt-8"> 224 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("settings.organizations.dangerZone")}</p> 225 - <p className="mt-2 text-sm leading-6 text-[var(--muted)]">{t("settings.organizations.deleteOrgHelp")}</p> 226 - <DeleteOrganizationButton 227 - organizationId={selectedOrganization.id} 228 - returnOrganizationId={selectedOrganization.id} 229 - /> 278 + <div className="flex items-center justify-between gap-3"> 279 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 280 + {t("settings.organizations.inviteLinks")} 281 + </p> 282 + {selectedOrganization.isOwner ? ( 283 + <form action={createOrganizationInviteLinkAction}> 284 + <input 285 + type="hidden" 286 + name="organizationId" 287 + value={selectedOrganization.id} 288 + /> 289 + <input 290 + type="hidden" 291 + name="returnOrganizationId" 292 + value={selectedOrganization.id} 293 + /> 294 + <Button variant="secondary" size="sm" type="submit"> 295 + <Link2 className="size-4" /> 296 + {t("settings.organizations.newInviteLink")} 297 + </Button> 298 + </form> 299 + ) : null} 300 + </div> 301 + <div className="mt-4 space-y-4"> 302 + {selectedOrganization.inviteLinks.length === 0 ? ( 303 + <div className="py-4 text-sm text-[var(--muted)]"> 304 + {t("settings.organizations.noInviteLinks")} 305 + </div> 306 + ) : null} 307 + {selectedOrganization.inviteLinks.map((inviteLink) => { 308 + return ( 309 + <div 310 + key={inviteLink.id} 311 + className="flex flex-col gap-3 py-1 sm:flex-row sm:items-center sm:justify-between" 312 + > 313 + <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"> 314 + <button 315 + type="button" 316 + onClick={() => copyInviteLink(inviteLink.token)} 317 + className="truncate font-mono text-[13px] text-[var(--ink)] transition hover:text-[var(--accent)]" 318 + > 319 + {`/join/${inviteLink.token}`} 320 + </button> 321 + <p className="text-xs text-[var(--muted)]"> 322 + {t("settings.organizations.inviteCreated", { 323 + date: formatDate( 324 + inviteLink.createdAt, 325 + locale, 326 + ), 327 + })} 328 + </p> 329 + </div> 330 + {selectedOrganization.isOwner ? ( 331 + <form action={revokeOrganizationInviteLinkAction}> 332 + <input 333 + type="hidden" 334 + name="organizationId" 335 + value={selectedOrganization.id} 336 + /> 337 + <input 338 + type="hidden" 339 + name="inviteLinkId" 340 + value={inviteLink.id} 341 + /> 342 + <input 343 + type="hidden" 344 + name="returnOrganizationId" 345 + value={selectedOrganization.id} 346 + /> 347 + <Button 348 + variant="secondary" 349 + size="sm" 350 + type="submit" 351 + > 352 + {t("settings.organizations.revokeInvite")} 353 + </Button> 354 + </form> 355 + ) : null} 356 + </div> 357 + ); 358 + })} 359 + </div> 230 360 </div> 231 - ) : null} 361 + 362 + {selectedOrganization.isOwner ? ( 363 + <div className="border-t border-[color:var(--line)] pt-8"> 364 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 365 + {t("settings.organizations.dangerZone")} 366 + </p> 367 + <p className="mt-2 text-sm leading-6 text-[var(--muted)]"> 368 + {t("settings.organizations.deleteOrgHelp")} 369 + </p> 370 + <DeleteOrganizationButton 371 + organizationId={selectedOrganization.id} 372 + returnOrganizationId={selectedOrganization.id} 373 + /> 374 + </div> 375 + ) : null} 376 + </div> 232 377 </div> 233 - </div> 234 - </> 235 - ) : null} 236 - </div> 378 + </> 379 + ) : null} 380 + </div> 237 381 </div> 238 382 </> 239 383 );
+66 -16
components/profile-settings-panel.tsx
··· 8 8 import { UserAvatar } from "@/components/user-avatar"; 9 9 import { Button } from "@/components/ui/button"; 10 10 import { Input } from "@/components/ui/input"; 11 - import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 11 + import { 12 + Select, 13 + SelectContent, 14 + SelectItem, 15 + SelectTrigger, 16 + SelectValue, 17 + } from "@/components/ui/select"; 12 18 import { supportedLocales } from "@/lib/i18n"; 13 19 import type { ProfileSettingsUserSummary } from "@/lib/form-types"; 14 20 import { useRouter } from "next/navigation"; ··· 39 45 return ( 40 46 <div> 41 47 <div> 42 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("settings.profile.title")}</h1> 48 + <h1 className="font-display text-4xl text-[var(--ink)]"> 49 + {t("settings.profile.title")} 50 + </h1> 43 51 <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 44 52 {t("settings.profile.description")} 45 53 </p> ··· 47 55 48 56 <form action={submitProfile} className="mt-8 space-y-8"> 49 57 <input type="hidden" name="returnSection" value="profile" /> 50 - <input type="hidden" name="returnOrganizationId" value={initialOrganizationId ?? ""} /> 58 + <input 59 + type="hidden" 60 + name="returnOrganizationId" 61 + value={initialOrganizationId ?? ""} 62 + /> 51 63 <input type="hidden" name="locale" value={locale} /> 52 64 53 65 <div className="flex flex-col gap-4 border-t border-[color:var(--line)] pt-8 sm:flex-row sm:items-center"> 54 - <UserAvatar user={user} className="size-18" initialsClassName="text-lg" /> 66 + <UserAvatar 67 + user={user} 68 + className="size-18" 69 + initialsClassName="text-lg" 70 + /> 55 71 <div className="min-w-0"> 56 - <p className="text-sm font-medium text-[var(--ink)]">{t("settings.profile.profilePicture")}</p> 72 + <p className="text-sm font-medium text-[var(--ink)]"> 73 + {t("settings.profile.profilePicture")} 74 + </p> 57 75 <p className="mt-2 max-w-xl text-sm leading-6 text-[var(--muted)]"> 58 76 {t("settings.profile.profilePictureDescription")} 59 77 </p> ··· 62 80 63 81 <div className="grid gap-5 sm:grid-cols-2"> 64 82 <label className="grid gap-2 text-sm text-[var(--muted)]"> 65 - <span className="font-medium text-[var(--ink)]">{t("settings.profile.firstName")}</span> 66 - <Input name="firstName" defaultValue={user.firstName ?? ""} placeholder={t("settings.profile.firstName")} /> 83 + <span className="font-medium text-[var(--ink)]"> 84 + {t("settings.profile.firstName")} 85 + </span> 86 + <Input 87 + name="firstName" 88 + defaultValue={user.firstName ?? ""} 89 + placeholder={t("settings.profile.firstName")} 90 + /> 67 91 </label> 68 92 <label className="grid gap-2 text-sm text-[var(--muted)]"> 69 - <span className="font-medium text-[var(--ink)]">{t("settings.profile.secondName")}</span> 70 - <Input name="secondName" defaultValue={user.secondName ?? ""} placeholder={t("settings.profile.secondName")} /> 93 + <span className="font-medium text-[var(--ink)]"> 94 + {t("settings.profile.secondName")} 95 + </span> 96 + <Input 97 + name="secondName" 98 + defaultValue={user.secondName ?? ""} 99 + placeholder={t("settings.profile.secondName")} 100 + /> 71 101 </label> 72 102 </div> 73 103 74 104 <label className="grid gap-2 text-sm text-[var(--muted)]"> 75 - <span className="font-medium text-[var(--ink)]">{t("settings.profile.imageUrl")}</span> 76 - <Input name="image" type="url" defaultValue={user.image ?? ""} placeholder="https://example.com/avatar.jpg" /> 77 - <span className="text-xs text-[var(--muted)]">{t("settings.profile.imageUrlHelp")}</span> 105 + <span className="font-medium text-[var(--ink)]"> 106 + {t("settings.profile.imageUrl")} 107 + </span> 108 + <Input 109 + name="image" 110 + type="url" 111 + defaultValue={user.image ?? ""} 112 + placeholder="https://example.com/avatar.jpg" 113 + /> 114 + <span className="text-xs text-[var(--muted)]"> 115 + {t("settings.profile.imageUrlHelp")} 116 + </span> 78 117 </label> 79 118 80 119 <div className="grid gap-2 text-sm text-[var(--muted)]"> 81 - <span className="font-medium text-[var(--ink)]">{t("settings.profile.locale")}</span> 82 - <Select value={locale} onValueChange={(value) => setLocale(value as typeof user.locale)}> 120 + <span className="font-medium text-[var(--ink)]"> 121 + {t("settings.profile.locale")} 122 + </span> 123 + <Select 124 + value={locale} 125 + onValueChange={(value) => setLocale(value as typeof user.locale)} 126 + > 83 127 <SelectTrigger className="h-11 px-4 text-sm font-medium"> 84 128 <SelectValue /> 85 129 </SelectTrigger> ··· 91 135 ))} 92 136 </SelectContent> 93 137 </Select> 94 - <span className="text-xs text-[var(--muted)]">{t("settings.profile.localeHelp")}</span> 138 + <span className="text-xs text-[var(--muted)]"> 139 + {t("settings.profile.localeHelp")} 140 + </span> 95 141 </div> 96 142 97 143 <div className="flex flex-wrap items-center justify-end gap-3 pt-2"> 98 144 <Button type="submit" disabled={isPending}> 99 - {isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />} 145 + {isPending ? ( 146 + <LoaderCircle className="size-4 animate-spin" /> 147 + ) : ( 148 + <Save className="size-4" /> 149 + )} 100 150 {t("settings.profile.save")} 101 151 </Button> 102 152 </div>
+603 -376
components/public-form-runner.tsx
··· 1 1 "use client"; 2 2 3 3 import { AnimatePresence, motion } from "framer-motion"; 4 - import { Check, Circle, CornerDownLeft, LoaderCircle, Square } from "lucide-react"; 4 + import { 5 + Check, 6 + Circle, 7 + CornerDownLeft, 8 + LoaderCircle, 9 + Square, 10 + } from "lucide-react"; 5 11 import Link from "next/link"; 6 - import { useCallback, useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; 12 + import { 13 + useCallback, 14 + useEffect, 15 + useMemo, 16 + useState, 17 + type KeyboardEvent as ReactKeyboardEvent, 18 + } from "react"; 7 19 8 20 import { useI18n } from "@/components/i18n-provider"; 9 21 import { Button, buttonVariants } from "@/components/ui/button"; ··· 26 38 type TextBlockConfig, 27 39 } from "@/lib/blocks"; 28 40 import { resolveNextBlockId } from "@/lib/branching"; 29 - import { isLegacyDefaultCompletionMessage, isLegacyDefaultCompletionTitle } from "@/lib/form-defaults"; 41 + import { 42 + isLegacyDefaultCompletionMessage, 43 + isLegacyDefaultCompletionTitle, 44 + } from "@/lib/form-defaults"; 30 45 import type { PublicForm } from "@/lib/form-types"; 31 46 import { cn } from "@/lib/utils"; 32 47 33 - async function submitResponse(slug: string, answers: Record<string, string | string[]>) { 48 + async function submitResponse( 49 + slug: string, 50 + answers: Record<string, string | string[]>, 51 + ) { 34 52 const response = await fetch(`/api/public/forms/${slug}/responses`, { 35 53 method: "POST", 36 54 headers: { ··· 39 57 body: JSON.stringify({ answers }), 40 58 }); 41 59 42 - const payload = (await response.json().catch(() => ({}))) as { error?: string; responseId?: string }; 60 + const payload = (await response.json().catch(() => ({}))) as { 61 + error?: string; 62 + responseId?: string; 63 + }; 43 64 44 65 if (!response.ok) { 45 66 throw new Error(payload.error ?? "Could not submit response."); ··· 49 70 } 50 71 51 72 export function PublicFormRunner({ form }: { form: PublicForm }) { 52 - const initialHistory = useMemo(() => (form.blocks[0] ? [form.blocks[0].id] : []), [form.blocks]); 73 + const initialHistory = useMemo( 74 + () => (form.blocks[0] ? [form.blocks[0].id] : []), 75 + [form.blocks], 76 + ); 53 77 const { t } = useI18n(); 54 78 const [answers, setAnswers] = useState<Record<string, string | string[]>>({}); 55 79 const [history, setHistory] = useState<string[]>(initialHistory); ··· 58 82 const [isSubmitting, setIsSubmitting] = useState(false); 59 83 const [isComplete, setIsComplete] = useState(false); 60 84 61 - const blocksById = useMemo(() => new Map(form.blocks.map((block) => [block.id, block])), [form.blocks]); 85 + const blocksById = useMemo( 86 + () => new Map(form.blocks.map((block) => [block.id, block])), 87 + [form.blocks], 88 + ); 62 89 const currentBlockId = history[cursor] ?? initialHistory[0] ?? null; 63 - const currentBlock = currentBlockId ? blocksById.get(currentBlockId) ?? null : null; 64 - const nextBlockId = currentBlock ? resolveNextBlockId(form.blocks, currentBlock.id, answers) : null; 65 - const visibleTotal = Math.max(cursor + 1, currentBlock && cursor === history.length - 1 && nextBlockId ? history.length + 1 : history.length || 1); 66 - const progress = useMemo(() => ((cursor + 1) / visibleTotal) * 100, [cursor, visibleTotal]); 90 + const currentBlock = currentBlockId 91 + ? (blocksById.get(currentBlockId) ?? null) 92 + : null; 93 + const nextBlockId = currentBlock 94 + ? resolveNextBlockId(form.blocks, currentBlock.id, answers) 95 + : null; 96 + const visibleTotal = Math.max( 97 + cursor + 1, 98 + currentBlock && cursor === history.length - 1 && nextBlockId 99 + ? history.length + 1 100 + : history.length || 1, 101 + ); 102 + const progress = useMemo( 103 + () => ((cursor + 1) / visibleTotal) * 100, 104 + [cursor, visibleTotal], 105 + ); 67 106 const completionTitle = 68 - !form.completionTitle.trim() || isLegacyDefaultCompletionTitle(form.completionTitle) 107 + !form.completionTitle.trim() || 108 + isLegacyDefaultCompletionTitle(form.completionTitle) 69 109 ? t("publicRunner.defaultCompletionTitle") 70 110 : form.completionTitle.trim(); 71 111 const completionMessage = 72 - !form.completionMessage.trim() || isLegacyDefaultCompletionMessage(form.completionMessage) 112 + !form.completionMessage.trim() || 113 + isLegacyDefaultCompletionMessage(form.completionMessage) 73 114 ? t("publicRunner.defaultCompletionMessage") 74 115 : form.completionMessage.trim(); 75 116 const completionLinkLabel = form.completionLinkLabel?.trim() || null; ··· 82 123 })); 83 124 } 84 125 85 - const showToast = useCallback((message: string, variant: ToastData["variant"] = "success") => { 86 - const id = crypto.randomUUID(); 87 - setToasts((current) => [...current, { id, message, variant }]); 88 - }, []); 126 + const showToast = useCallback( 127 + (message: string, variant: ToastData["variant"] = "success") => { 128 + const id = crypto.randomUUID(); 129 + setToasts((current) => [...current, { id, message, variant }]); 130 + }, 131 + [], 132 + ); 89 133 90 134 const dismissToast = useCallback((id: string) => { 91 135 setToasts((current) => current.filter((toast) => toast.id !== id)); 92 136 }, []); 93 137 94 - const validateStep = useCallback((answerSet: Record<string, string | string[]> = answers) => { 95 - if (!currentBlock || currentBlock.type === "TEXT") { 96 - return true; 97 - } 138 + const validateStep = useCallback( 139 + (answerSet: Record<string, string | string[]> = answers) => { 140 + if (!currentBlock || currentBlock.type === "TEXT") { 141 + return true; 142 + } 143 + 144 + const value = answerSet[currentBlock.id]; 98 145 99 - const value = answerSet[currentBlock.id]; 146 + if ( 147 + currentBlock.type === "SHORT_TEXT" || 148 + currentBlock.type === "LONG_TEXT" 149 + ) { 150 + const textValue = typeof value === "string" ? value.trim() : ""; 100 151 101 - if (currentBlock.type === "SHORT_TEXT" || currentBlock.type === "LONG_TEXT") { 102 - const textValue = typeof value === "string" ? value.trim() : ""; 152 + if (!textValue) { 153 + if (!currentBlock.required) { 154 + return true; 155 + } 103 156 104 - if (!textValue) { 105 - if (!currentBlock.required) { 106 - return true; 157 + showToast(t("publicRunner.answerRequired"), "error"); 158 + return false; 107 159 } 108 160 109 - showToast(t("publicRunner.answerRequired"), "error"); 110 - return false; 111 - } 161 + const validationRegex = getTextValidationPattern( 162 + currentBlock.config as ShortTextBlockConfig | LongTextBlockConfig, 163 + ); 112 164 113 - const validationRegex = getTextValidationPattern(currentBlock.config as ShortTextBlockConfig | LongTextBlockConfig); 165 + if (validationRegex && !new RegExp(validationRegex).test(textValue)) { 166 + showToast(t("publicRunner.invalidTextFormat"), "error"); 167 + return false; 168 + } 114 169 115 - if (validationRegex && !new RegExp(validationRegex).test(textValue)) { 116 - showToast(t("publicRunner.invalidTextFormat"), "error"); 117 - return false; 170 + return true; 118 171 } 119 172 120 - return true; 121 - } 173 + if (currentBlock.type === "SINGLE_CHOICE") { 174 + if (typeof value === "string" && value.trim()) { 175 + return true; 176 + } 122 177 123 - if (currentBlock.type === "SINGLE_CHOICE") { 124 - if (typeof value === "string" && value.trim()) { 178 + if (currentBlock.required) { 179 + showToast(t("publicRunner.singleChoiceRequired"), "error"); 180 + return false; 181 + } 182 + 125 183 return true; 126 184 } 127 185 128 - if (currentBlock.required) { 129 - showToast(t("publicRunner.singleChoiceRequired"), "error"); 130 - return false; 131 - } 186 + if (currentBlock.type === "MULTIPLE_CHOICE") { 187 + if (Array.isArray(value) && value.length > 0) { 188 + return true; 189 + } 132 190 133 - return true; 134 - } 191 + if (currentBlock.required) { 192 + showToast(t("publicRunner.multiChoiceRequired"), "error"); 193 + return false; 194 + } 135 195 136 - if (currentBlock.type === "MULTIPLE_CHOICE") { 137 - if (Array.isArray(value) && value.length > 0) { 138 196 return true; 139 197 } 140 198 141 - if (currentBlock.required) { 142 - showToast(t("publicRunner.multiChoiceRequired"), "error"); 143 - return false; 144 - } 199 + if (currentBlock.type === "NUMBER") { 200 + const textValue = typeof value === "string" ? value.trim() : ""; 201 + const config = currentBlock.config as NumberBlockConfig; 145 202 146 - return true; 147 - } 203 + if (!textValue) { 204 + if (!currentBlock.required) { 205 + return true; 206 + } 148 207 149 - if (currentBlock.type === "NUMBER") { 150 - const textValue = typeof value === "string" ? value.trim() : ""; 151 - const config = currentBlock.config as NumberBlockConfig; 152 - 153 - if (!textValue) { 154 - if (!currentBlock.required) { 155 - return true; 208 + showToast(t("publicRunner.answerRequired"), "error"); 209 + return false; 156 210 } 157 211 158 - showToast(t("publicRunner.answerRequired"), "error"); 159 - return false; 160 - } 212 + const numericValue = parseNumericAnswer(textValue); 161 213 162 - const numericValue = parseNumericAnswer(textValue); 214 + if (numericValue === null) { 215 + showToast(t("publicRunner.invalidNumber"), "error"); 216 + return false; 217 + } 163 218 164 - if (numericValue === null) { 165 - showToast(t("publicRunner.invalidNumber"), "error"); 166 - return false; 167 - } 219 + if (!config.allowFloat && !Number.isInteger(numericValue)) { 220 + showToast(t("publicRunner.wholeNumberRequired"), "error"); 221 + return false; 222 + } 168 223 169 - if (!config.allowFloat && !Number.isInteger(numericValue)) { 170 - showToast(t("publicRunner.wholeNumberRequired"), "error"); 171 - return false; 172 - } 224 + if (config.min !== null && numericValue < config.min) { 225 + showToast( 226 + t("publicRunner.numberAtLeast", { min: config.min }), 227 + "error", 228 + ); 229 + return false; 230 + } 173 231 174 - if (config.min !== null && numericValue < config.min) { 175 - showToast(t("publicRunner.numberAtLeast", { min: config.min }), "error"); 176 - return false; 177 - } 232 + if (config.max !== null && numericValue > config.max) { 233 + showToast( 234 + t("publicRunner.numberAtMost", { max: config.max }), 235 + "error", 236 + ); 237 + return false; 238 + } 178 239 179 - if (config.max !== null && numericValue > config.max) { 180 - showToast(t("publicRunner.numberAtMost", { max: config.max }), "error"); 181 - return false; 240 + return true; 182 241 } 183 242 184 - return true; 185 - } 243 + if (currentBlock.type === "LINK") { 244 + const textValue = typeof value === "string" ? value.trim() : ""; 186 245 187 - if (currentBlock.type === "LINK") { 188 - const textValue = typeof value === "string" ? value.trim() : ""; 246 + if (!textValue) { 247 + if (!currentBlock.required) { 248 + return true; 249 + } 189 250 190 - if (!textValue) { 191 - if (!currentBlock.required) { 192 - return true; 251 + showToast(t("publicRunner.answerRequired"), "error"); 252 + return false; 193 253 } 194 254 195 - showToast(t("publicRunner.answerRequired"), "error"); 196 - return false; 197 - } 255 + if (!isValidLinkAnswer(textValue)) { 256 + showToast(t("publicRunner.invalidLink"), "error"); 257 + return false; 258 + } 198 259 199 - if (!isValidLinkAnswer(textValue)) { 200 - showToast(t("publicRunner.invalidLink"), "error"); 201 - return false; 260 + return true; 202 261 } 203 262 204 - return true; 205 - } 263 + if (currentBlock.type === "DATE") { 264 + const textValue = typeof value === "string" ? value.trim() : ""; 206 265 207 - if (currentBlock.type === "DATE") { 208 - const textValue = typeof value === "string" ? value.trim() : ""; 266 + if (!textValue) { 267 + if (!currentBlock.required) { 268 + return true; 269 + } 209 270 210 - if (!textValue) { 211 - if (!currentBlock.required) { 212 - return true; 271 + showToast(t("publicRunner.answerRequired"), "error"); 272 + return false; 213 273 } 214 274 215 - showToast(t("publicRunner.answerRequired"), "error"); 216 - return false; 217 - } 275 + if (!isValidDateAnswer(textValue)) { 276 + showToast(t("publicRunner.invalidDate"), "error"); 277 + return false; 278 + } 218 279 219 - if (!isValidDateAnswer(textValue)) { 220 - showToast(t("publicRunner.invalidDate"), "error"); 221 - return false; 280 + return true; 222 281 } 223 282 224 - return true; 225 - } 283 + if (currentBlock.type === "AGREEMENT") { 284 + const textValue = typeof value === "string" ? value.trim() : ""; 226 285 227 - if (currentBlock.type === "AGREEMENT") { 228 - const textValue = typeof value === "string" ? value.trim() : ""; 286 + if (!textValue) { 287 + if (!currentBlock.required) { 288 + return true; 289 + } 229 290 230 - if (!textValue) { 231 - if (!currentBlock.required) { 232 - return true; 291 + showToast(t("publicRunner.agreementRequired"), "error"); 292 + return false; 233 293 } 234 294 235 - showToast(t("publicRunner.agreementRequired"), "error"); 236 - return false; 237 - } 295 + if ( 296 + currentBlock.required && 297 + textValue !== AGREEMENT_ANSWER_VALUES.AGREED 298 + ) { 299 + showToast(t("publicRunner.agreementRequired"), "error"); 300 + return false; 301 + } 238 302 239 - if (currentBlock.required && textValue !== AGREEMENT_ANSWER_VALUES.AGREED) { 240 - showToast(t("publicRunner.agreementRequired"), "error"); 241 - return false; 303 + return ( 304 + textValue === AGREEMENT_ANSWER_VALUES.AGREED || 305 + textValue === AGREEMENT_ANSWER_VALUES.NOT_AGREED 306 + ); 242 307 } 243 308 244 - return textValue === AGREEMENT_ANSWER_VALUES.AGREED || textValue === AGREEMENT_ANSWER_VALUES.NOT_AGREED; 245 - } 309 + return true; 310 + }, 311 + [answers, currentBlock, showToast, t], 312 + ); 246 313 247 - return true; 248 - }, [answers, currentBlock, showToast, t]); 314 + const handleContinue = useCallback( 315 + async (answerSet: Record<string, string | string[]> = answers) => { 316 + if (!validateStep(answerSet) || !currentBlock) { 317 + return; 318 + } 249 319 250 - const handleContinue = useCallback(async (answerSet: Record<string, string | string[]> = answers) => { 251 - if (!validateStep(answerSet) || !currentBlock) { 252 - return; 253 - } 320 + const resolvedNextBlockId = resolveNextBlockId( 321 + form.blocks, 322 + currentBlock.id, 323 + answerSet, 324 + ); 254 325 255 - const resolvedNextBlockId = resolveNextBlockId(form.blocks, currentBlock.id, answerSet); 326 + if (!resolvedNextBlockId) { 327 + try { 328 + setIsSubmitting(true); 329 + await submitResponse(form.slug, answerSet); 330 + setIsComplete(true); 331 + } catch (caughtError) { 332 + showToast( 333 + caughtError instanceof Error 334 + ? caughtError.message 335 + : t("publicRunner.submitError"), 336 + "error", 337 + ); 338 + } finally { 339 + setIsSubmitting(false); 340 + } 256 341 257 - if (!resolvedNextBlockId) { 258 - try { 259 - setIsSubmitting(true); 260 - await submitResponse(form.slug, answerSet); 261 - setIsComplete(true); 262 - } catch (caughtError) { 263 - showToast(caughtError instanceof Error ? caughtError.message : t("publicRunner.submitError"), "error"); 264 - } finally { 265 - setIsSubmitting(false); 342 + return; 266 343 } 267 344 268 - return; 269 - } 270 - 271 - setHistory((current) => [...current.slice(0, cursor + 1), resolvedNextBlockId]); 272 - setCursor((current) => current + 1); 273 - }, [answers, currentBlock, cursor, form.blocks, form.slug, showToast, t, validateStep]); 345 + setHistory((current) => [ 346 + ...current.slice(0, cursor + 1), 347 + resolvedNextBlockId, 348 + ]); 349 + setCursor((current) => current + 1); 350 + }, 351 + [ 352 + answers, 353 + currentBlock, 354 + cursor, 355 + form.blocks, 356 + form.slug, 357 + showToast, 358 + t, 359 + validateStep, 360 + ], 361 + ); 274 362 275 363 const handleBack = useCallback(() => { 276 364 setCursor((current) => Math.max(0, current - 1)); ··· 280 368 event: ReactKeyboardEvent<HTMLElement>, 281 369 options?: { allowShiftEnter?: boolean }, 282 370 ) { 283 - if (event.key !== "Enter" || event.nativeEvent.isComposing || isSubmitting) { 371 + if ( 372 + event.key !== "Enter" || 373 + event.nativeEvent.isComposing || 374 + isSubmitting 375 + ) { 284 376 return; 285 377 } 286 378 ··· 296 388 event: ReactKeyboardEvent<HTMLButtonElement>, 297 389 nextAnswer: string | string[], 298 390 ) { 299 - if (event.key !== "Enter" || event.nativeEvent.isComposing || isSubmitting || !currentBlock) { 391 + if ( 392 + event.key !== "Enter" || 393 + event.nativeEvent.isComposing || 394 + isSubmitting || 395 + !currentBlock 396 + ) { 300 397 return; 301 398 } 302 399 ··· 345 442 346 443 window.addEventListener("keydown", handleWindowKeyDown); 347 444 return () => window.removeEventListener("keydown", handleWindowKeyDown); 348 - }, [currentBlock, cursor, handleBack, handleContinue, isComplete, isSubmitting]); 445 + }, [ 446 + currentBlock, 447 + cursor, 448 + handleBack, 449 + handleContinue, 450 + isComplete, 451 + isSubmitting, 452 + ]); 349 453 350 454 if (isComplete) { 351 455 return ( 352 456 <Card className="w-full max-w-3xl overflow-hidden"> 353 457 <div className="border-b border-[color:var(--line)] bg-[var(--accent-soft)]/55 px-6 py-6 sm:px-8"> 354 - <p className="text-xs font-semibold uppercase text-[var(--accent)]">{t("publicRunner.responseReceived")}</p> 458 + <p className="text-xs font-semibold uppercase text-[var(--accent)]"> 459 + {t("publicRunner.responseReceived")} 460 + </p> 355 461 </div> 356 462 <div className="px-6 py-8 sm:px-8 sm:py-10"> 357 - <h2 className="max-w-2xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl">{completionTitle}</h2> 358 - <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]">{completionMessage}</p> 463 + <h2 className="max-w-2xl font-display text-4xl leading-tight text-[var(--ink)] sm:text-5xl"> 464 + {completionTitle} 465 + </h2> 466 + <p className="mt-5 max-w-2xl text-base leading-8 text-[var(--muted)]"> 467 + {completionMessage} 468 + </p> 359 469 {completionLinkLabel && completionLinkUrl ? ( 360 470 <div className="mt-8"> 361 471 <Link 362 472 href={completionLinkUrl} 363 473 target="_blank" 364 474 rel="noreferrer" 365 - className={buttonVariants({ variant: "default", className: "w-fit !bg-[var(--ink)] !text-[var(--bg)]" })} 475 + className={buttonVariants({ 476 + variant: "default", 477 + className: "w-fit !bg-[var(--ink)] !text-[var(--bg)]", 478 + })} 366 479 > 367 480 {completionLinkLabel} 368 481 </Link> ··· 381 494 <> 382 495 <ToastViewport toasts={toasts} onDismiss={dismissToast} /> 383 496 <Card className="w-full max-w-5xl overflow-hidden"> 384 - <div className="border-b border-[color:var(--line)] px-5 py-5 sm:px-6 sm:py-6"> 385 - <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 386 - <div className="min-w-0"> 387 - <h1 className="font-display text-2xl leading-tight text-[var(--ink)] sm:text-3xl">{form.title}</h1> 388 - </div> 389 - 390 - <div className="w-full max-w-xs shrink-0"> 391 - <div className="flex items-center justify-between gap-3"> 392 - <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)]"> 393 - {t("publicRunner.step", { current: cursor + 1, total: visibleTotal })} 394 - </span> 395 - <span className="text-xs font-medium uppercase tracking-[0.2em] text-[var(--muted)]">{Math.round(progress)}%</span> 497 + <div className="border-b border-[color:var(--line)] px-5 py-5 sm:px-6 sm:py-6"> 498 + <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> 499 + <div className="min-w-0"> 500 + <h1 className="font-display text-2xl leading-tight text-[var(--ink)] sm:text-3xl"> 501 + {form.title} 502 + </h1> 396 503 </div> 397 - <div className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--bg-strong)]"> 398 - <motion.div 399 - className="h-full rounded-full bg-[var(--accent)]" 400 - animate={{ width: `${progress}%` }} 401 - transition={{ type: "spring", stiffness: 130, damping: 20 }} 402 - /> 504 + 505 + <div className="w-full max-w-xs shrink-0"> 506 + <div className="flex items-center justify-between gap-3"> 507 + <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)]"> 508 + {t("publicRunner.step", { 509 + current: cursor + 1, 510 + total: visibleTotal, 511 + })} 512 + </span> 513 + <span className="text-xs font-medium uppercase tracking-[0.2em] text-[var(--muted)]"> 514 + {Math.round(progress)}% 515 + </span> 516 + </div> 517 + <div className="mt-3 h-2 overflow-hidden rounded-full bg-[var(--bg-strong)]"> 518 + <motion.div 519 + className="h-full rounded-full bg-[var(--accent)]" 520 + animate={{ width: `${progress}%` }} 521 + transition={{ type: "spring", stiffness: 130, damping: 20 }} 522 + /> 523 + </div> 403 524 </div> 404 525 </div> 405 526 </div> 406 - </div> 407 527 408 - <div className="px-5 py-6 sm:px-6 sm:py-7"> 409 - <AnimatePresence mode="wait"> 410 - <motion.div 411 - key={currentBlock.id} 412 - initial={{ opacity: 0, y: 16 }} 413 - animate={{ opacity: 1, y: 0 }} 414 - exit={{ opacity: 0, y: -16 }} 415 - transition={{ duration: 0.24, ease: "easeOut" }} 416 - className="min-h-[280px]" 417 - > 418 - {currentBlock.type === "TEXT" ? ( 419 - <div className="flex min-h-[240px] flex-col justify-center py-4"> 420 - <h2 className="font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl"> 421 - {currentBlock.title || t("publicRunner.defaultTextTitle")} 422 - </h2> 423 - <p className="mt-4 whitespace-pre-wrap text-base leading-7 text-[var(--ink)]/82">{(currentBlock.config as TextBlockConfig).body}</p> 424 - </div> 425 - ) : ( 426 - <div className="flex min-h-[240px] flex-col justify-center py-2"> 427 - <div className="flex flex-wrap items-center gap-3"> 428 - {currentBlock.required ? ( 429 - <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)]"> 430 - {t("publicRunner.required")} 431 - </span> 432 - ) : null} 528 + <div className="px-5 py-6 sm:px-6 sm:py-7"> 529 + <AnimatePresence mode="wait"> 530 + <motion.div 531 + key={currentBlock.id} 532 + initial={{ opacity: 0, y: 16 }} 533 + animate={{ opacity: 1, y: 0 }} 534 + exit={{ opacity: 0, y: -16 }} 535 + transition={{ duration: 0.24, ease: "easeOut" }} 536 + className="min-h-[280px]" 537 + > 538 + {currentBlock.type === "TEXT" ? ( 539 + <div className="flex min-h-[240px] flex-col justify-center py-4"> 540 + <h2 className="font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl"> 541 + {currentBlock.title || t("publicRunner.defaultTextTitle")} 542 + </h2> 543 + <p className="mt-4 whitespace-pre-wrap text-base leading-7 text-[var(--ink)]/82"> 544 + {(currentBlock.config as TextBlockConfig).body} 545 + </p> 433 546 </div> 547 + ) : ( 548 + <div className="flex min-h-[240px] flex-col justify-center py-2"> 549 + <div className="flex flex-wrap items-center gap-3"> 550 + {currentBlock.required ? ( 551 + <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)]"> 552 + {t("publicRunner.required")} 553 + </span> 554 + ) : null} 555 + </div> 434 556 435 - <h2 className="font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl"> 436 - {currentBlock.title} 437 - </h2> 438 - {currentBlock.description ? <p className="mt-3 text-base leading-7 text-[var(--muted)]">{currentBlock.description}</p> : null} 439 - 440 - <div className="mt-6 space-y-3"> 441 - {currentBlock.type === "SINGLE_CHOICE" ? ( 442 - <p className="text-sm font-medium text-[var(--muted)]">{t("publicRunner.chooseOne")}</p> 557 + <h2 className="font-display text-3xl leading-tight text-[var(--ink)] sm:text-4xl"> 558 + {currentBlock.title} 559 + </h2> 560 + {currentBlock.description ? ( 561 + <p className="mt-3 text-base leading-7 text-[var(--muted)]"> 562 + {currentBlock.description} 563 + </p> 443 564 ) : null} 444 565 445 - {currentBlock.type === "MULTIPLE_CHOICE" ? ( 446 - <p className="text-sm font-medium text-[var(--muted)]">{t("publicRunner.selectAll")}</p> 447 - ) : null} 448 - {currentBlock.type === "SHORT_TEXT" ? ( 449 - <Input 450 - className="h-12 text-base placeholder:text-[var(--muted)]/65" 451 - placeholder={(currentBlock.config as ShortTextBlockConfig).placeholder} 452 - value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 453 - onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 454 - onKeyDown={handleAdvanceKeyDown} 455 - /> 456 - ) : null} 566 + <div className="mt-6 space-y-3"> 567 + {currentBlock.type === "SINGLE_CHOICE" ? ( 568 + <p className="text-sm font-medium text-[var(--muted)]"> 569 + {t("publicRunner.chooseOne")} 570 + </p> 571 + ) : null} 572 + 573 + {currentBlock.type === "MULTIPLE_CHOICE" ? ( 574 + <p className="text-sm font-medium text-[var(--muted)]"> 575 + {t("publicRunner.selectAll")} 576 + </p> 577 + ) : null} 578 + {currentBlock.type === "SHORT_TEXT" ? ( 579 + <Input 580 + className="h-12 text-base placeholder:text-[var(--muted)]/65" 581 + placeholder={ 582 + (currentBlock.config as ShortTextBlockConfig) 583 + .placeholder 584 + } 585 + value={ 586 + typeof answers[currentBlock.id] === "string" 587 + ? answers[currentBlock.id] 588 + : "" 589 + } 590 + onChange={(event) => 591 + setAnswer(currentBlock.id, event.target.value) 592 + } 593 + onKeyDown={handleAdvanceKeyDown} 594 + /> 595 + ) : null} 457 596 458 - {currentBlock.type === "LONG_TEXT" ? ( 459 - <Textarea 460 - className="min-h-[140px] text-base placeholder:text-[var(--muted)]/65" 461 - placeholder={(currentBlock.config as LongTextBlockConfig).placeholder} 462 - value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 463 - onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 464 - onKeyDown={(event) => handleAdvanceKeyDown(event, { allowShiftEnter: true })} 465 - /> 466 - ) : null} 597 + {currentBlock.type === "LONG_TEXT" ? ( 598 + <Textarea 599 + className="min-h-[140px] text-base placeholder:text-[var(--muted)]/65" 600 + placeholder={ 601 + (currentBlock.config as LongTextBlockConfig) 602 + .placeholder 603 + } 604 + value={ 605 + typeof answers[currentBlock.id] === "string" 606 + ? answers[currentBlock.id] 607 + : "" 608 + } 609 + onChange={(event) => 610 + setAnswer(currentBlock.id, event.target.value) 611 + } 612 + onKeyDown={(event) => 613 + handleAdvanceKeyDown(event, { allowShiftEnter: true }) 614 + } 615 + /> 616 + ) : null} 467 617 468 - {currentBlock.type === "NUMBER" ? ( 469 - <Input 470 - type="number" 471 - step={(currentBlock.config as NumberBlockConfig).allowFloat ? "any" : "1"} 472 - className="h-12 text-base placeholder:text-[var(--muted)]/65" 473 - placeholder={(currentBlock.config as NumberBlockConfig).placeholder} 474 - value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 475 - onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 476 - onKeyDown={handleAdvanceKeyDown} 477 - /> 478 - ) : null} 618 + {currentBlock.type === "NUMBER" ? ( 619 + <Input 620 + type="number" 621 + step={ 622 + (currentBlock.config as NumberBlockConfig).allowFloat 623 + ? "any" 624 + : "1" 625 + } 626 + className="h-12 text-base placeholder:text-[var(--muted)]/65" 627 + placeholder={ 628 + (currentBlock.config as NumberBlockConfig).placeholder 629 + } 630 + value={ 631 + typeof answers[currentBlock.id] === "string" 632 + ? answers[currentBlock.id] 633 + : "" 634 + } 635 + onChange={(event) => 636 + setAnswer(currentBlock.id, event.target.value) 637 + } 638 + onKeyDown={handleAdvanceKeyDown} 639 + /> 640 + ) : null} 479 641 480 - {currentBlock.type === "LINK" ? ( 481 - <Input 482 - type="url" 483 - className="h-12 text-base placeholder:text-[var(--muted)]/65" 484 - placeholder={(currentBlock.config as LinkBlockConfig).placeholder} 485 - value={typeof answers[currentBlock.id] === "string" ? answers[currentBlock.id] : ""} 486 - onChange={(event) => setAnswer(currentBlock.id, event.target.value)} 487 - onKeyDown={handleAdvanceKeyDown} 488 - /> 489 - ) : null} 642 + {currentBlock.type === "LINK" ? ( 643 + <Input 644 + type="url" 645 + className="h-12 text-base placeholder:text-[var(--muted)]/65" 646 + placeholder={ 647 + (currentBlock.config as LinkBlockConfig).placeholder 648 + } 649 + value={ 650 + typeof answers[currentBlock.id] === "string" 651 + ? answers[currentBlock.id] 652 + : "" 653 + } 654 + onChange={(event) => 655 + setAnswer(currentBlock.id, event.target.value) 656 + } 657 + onKeyDown={handleAdvanceKeyDown} 658 + /> 659 + ) : null} 490 660 491 - {currentBlock.type === "DATE" 492 - ? (() => { 493 - const currentValue = answers[currentBlock.id]; 661 + {currentBlock.type === "DATE" 662 + ? (() => { 663 + const currentValue = answers[currentBlock.id]; 494 664 495 - return ( 496 - <DatePickerInput 497 - className="text-base" 498 - value={typeof currentValue === "string" ? currentValue : ""} 499 - onChange={(value) => setAnswer(currentBlock.id, value)} 500 - onKeyDown={handleAdvanceKeyDown} 501 - openCalendarLabel={t("publicRunner.openCalendar")} 502 - /> 503 - ); 504 - })() 505 - : null} 665 + return ( 666 + <DatePickerInput 667 + className="text-base" 668 + value={ 669 + typeof currentValue === "string" 670 + ? currentValue 671 + : "" 672 + } 673 + onChange={(value) => 674 + setAnswer(currentBlock.id, value) 675 + } 676 + onKeyDown={handleAdvanceKeyDown} 677 + openCalendarLabel={t("publicRunner.openCalendar")} 678 + /> 679 + ); 680 + })() 681 + : null} 506 682 507 - {currentBlock.type === "SINGLE_CHOICE" ? ( 508 - <div className="grid gap-3"> 509 - {(currentBlock.config as ChoiceBlockConfig).options.map((option) => { 510 - const selected = answers[currentBlock.id] === option; 683 + {currentBlock.type === "SINGLE_CHOICE" ? ( 684 + <div className="grid gap-3"> 685 + {(currentBlock.config as ChoiceBlockConfig).options.map( 686 + (option) => { 687 + const selected = 688 + answers[currentBlock.id] === option; 511 689 512 - return ( 513 - <button 514 - key={option} 515 - type="button" 516 - className={cn( 517 - "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 518 - selected 519 - ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 520 - : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 521 - )} 522 - onClick={() => setAnswer(currentBlock.id, option)} 523 - onKeyDown={(event) => handleChoiceEnter(event, option)} 524 - > 525 - <div className="flex items-center gap-3"> 526 - <span className="inline-flex size-5 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface)]"> 527 - {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Circle className="size-3.5 text-[var(--muted)]/55" />} 528 - </span> 529 - <span>{option}</span> 530 - </div> 531 - </button> 532 - ); 533 - })} 534 - </div> 535 - ) : null} 690 + return ( 691 + <button 692 + key={option} 693 + type="button" 694 + className={cn( 695 + "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 696 + selected 697 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 698 + : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 699 + )} 700 + onClick={() => 701 + setAnswer(currentBlock.id, option) 702 + } 703 + onKeyDown={(event) => 704 + handleChoiceEnter(event, option) 705 + } 706 + > 707 + <div className="flex items-center gap-3"> 708 + <span className="inline-flex size-5 items-center justify-center rounded-full border border-[color:var(--line)] bg-[var(--surface)]"> 709 + {selected ? ( 710 + <Check className="size-3.5 text-[var(--accent)]" /> 711 + ) : ( 712 + <Circle className="size-3.5 text-[var(--muted)]/55" /> 713 + )} 714 + </span> 715 + <span>{option}</span> 716 + </div> 717 + </button> 718 + ); 719 + }, 720 + )} 721 + </div> 722 + ) : null} 536 723 537 - {currentBlock.type === "MULTIPLE_CHOICE" ? ( 538 - <div className="grid gap-3"> 539 - {(currentBlock.config as ChoiceBlockConfig).options.map((option) => { 540 - const rawValues = answers[currentBlock.id]; 541 - const currentValues: string[] = Array.isArray(rawValues) ? rawValues : []; 542 - const selected = currentValues.includes(option); 724 + {currentBlock.type === "MULTIPLE_CHOICE" ? ( 725 + <div className="grid gap-3"> 726 + {(currentBlock.config as ChoiceBlockConfig).options.map( 727 + (option) => { 728 + const rawValues = answers[currentBlock.id]; 729 + const currentValues: string[] = Array.isArray( 730 + rawValues, 731 + ) 732 + ? rawValues 733 + : []; 734 + const selected = currentValues.includes(option); 543 735 544 - return ( 545 - <button 546 - key={option} 547 - type="button" 548 - className={cn( 549 - "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 550 - selected 551 - ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 552 - : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 553 - )} 554 - onClick={() => { 555 - const nextValues = selected 556 - ? currentValues.filter((value) => value !== option) 557 - : [...currentValues, option]; 558 - setAnswer(currentBlock.id, nextValues); 559 - }} 560 - onKeyDown={(event) => 561 - handleChoiceEnter( 562 - event, 563 - selected 564 - ? currentValues.filter((value) => value !== option) 565 - : [...currentValues, option], 566 - ) 567 - } 568 - > 569 - <div className="flex items-center gap-3"> 570 - <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> 571 - {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Square className="size-3.5 text-[var(--muted)]/55" />} 572 - </span> 573 - <span>{option}</span> 574 - </div> 575 - </button> 576 - ); 577 - })} 578 - </div> 579 - ) : null} 736 + return ( 737 + <button 738 + key={option} 739 + type="button" 740 + className={cn( 741 + "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 742 + selected 743 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 744 + : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 745 + )} 746 + onClick={() => { 747 + const nextValues = selected 748 + ? currentValues.filter( 749 + (value) => value !== option, 750 + ) 751 + : [...currentValues, option]; 752 + setAnswer(currentBlock.id, nextValues); 753 + }} 754 + onKeyDown={(event) => 755 + handleChoiceEnter( 756 + event, 757 + selected 758 + ? currentValues.filter( 759 + (value) => value !== option, 760 + ) 761 + : [...currentValues, option], 762 + ) 763 + } 764 + > 765 + <div className="flex items-center gap-3"> 766 + <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> 767 + {selected ? ( 768 + <Check className="size-3.5 text-[var(--accent)]" /> 769 + ) : ( 770 + <Square className="size-3.5 text-[var(--muted)]/55" /> 771 + )} 772 + </span> 773 + <span>{option}</span> 774 + </div> 775 + </button> 776 + ); 777 + }, 778 + )} 779 + </div> 780 + ) : null} 580 781 581 - {currentBlock.type === "AGREEMENT" ? ( 582 - <div className="grid gap-3"> 583 - {(() => { 584 - const selected = answers[currentBlock.id] === AGREEMENT_ANSWER_VALUES.AGREED; 585 - const label = "label" in currentBlock.config ? currentBlock.config.label : t("publicRunner.agree"); 586 - const nextValue = selected ? "" : AGREEMENT_ANSWER_VALUES.AGREED; 782 + {currentBlock.type === "AGREEMENT" ? ( 783 + <div className="grid gap-3"> 784 + {(() => { 785 + const selected = 786 + answers[currentBlock.id] === 787 + AGREEMENT_ANSWER_VALUES.AGREED; 788 + const label = 789 + "label" in currentBlock.config 790 + ? currentBlock.config.label 791 + : t("publicRunner.agree"); 792 + const nextValue = selected 793 + ? "" 794 + : AGREEMENT_ANSWER_VALUES.AGREED; 587 795 588 - return ( 589 - <button 590 - type="button" 591 - className={cn( 592 - "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 593 - selected 594 - ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 595 - : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 596 - )} 597 - onClick={() => setAnswer(currentBlock.id, nextValue)} 598 - onKeyDown={(event) => handleChoiceEnter(event, nextValue)} 599 - > 600 - <div className="flex items-center gap-3"> 601 - <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> 602 - {selected ? <Check className="size-3.5 text-[var(--accent)]" /> : <Square className="size-3.5 text-[var(--muted)]/55" />} 603 - </span> 604 - <span>{label}</span> 605 - </div> 606 - </button> 607 - ); 608 - })()} 609 - </div> 610 - ) : null} 796 + return ( 797 + <button 798 + type="button" 799 + className={cn( 800 + "flex items-center justify-between rounded-[16px] border px-4 py-3 text-left transition", 801 + selected 802 + ? "border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--ink)] shadow-[var(--shadow-accent)]" 803 + : "border-[color:var(--line)] bg-[var(--surface-strong)] text-[var(--ink)] hover:bg-[var(--surface)]", 804 + )} 805 + onClick={() => 806 + setAnswer(currentBlock.id, nextValue) 807 + } 808 + onKeyDown={(event) => 809 + handleChoiceEnter(event, nextValue) 810 + } 811 + > 812 + <div className="flex items-center gap-3"> 813 + <span className="inline-flex size-5 items-center justify-center rounded-[6px] border border-[color:var(--line)] bg-[var(--surface)]"> 814 + {selected ? ( 815 + <Check className="size-3.5 text-[var(--accent)]" /> 816 + ) : ( 817 + <Square className="size-3.5 text-[var(--muted)]/55" /> 818 + )} 819 + </span> 820 + <span>{label}</span> 821 + </div> 822 + </button> 823 + ); 824 + })()} 825 + </div> 826 + ) : null} 827 + </div> 611 828 </div> 612 - </div> 613 - )} 614 - </motion.div> 615 - </AnimatePresence> 829 + )} 830 + </motion.div> 831 + </AnimatePresence> 616 832 617 - <div className="mt-6 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-4"> 618 - <Button variant="secondary" onClick={handleBack} disabled={cursor === 0 || isSubmitting}> 619 - {t("publicRunner.back")} 620 - <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"> 621 - Esc 622 - </span> 623 - </Button> 624 - <Button onClick={() => void handleContinue()} disabled={isSubmitting}> 625 - {isSubmitting ? <LoaderCircle className="size-4 animate-spin" /> : null} 626 - {!nextBlockId ? t("publicRunner.submit") : t("publicRunner.continue")} 627 - {!isSubmitting ? ( 628 - <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"> 629 - <CornerDownLeft className="size-3" /> 833 + <div className="mt-6 flex items-center justify-between gap-3 border-t border-[color:var(--line)] pt-4"> 834 + <Button 835 + variant="secondary" 836 + onClick={handleBack} 837 + disabled={cursor === 0 || isSubmitting} 838 + > 839 + {t("publicRunner.back")} 840 + <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"> 841 + Esc 630 842 </span> 631 - ) : null} 632 - </Button> 843 + </Button> 844 + <Button 845 + onClick={() => void handleContinue()} 846 + disabled={isSubmitting} 847 + > 848 + {isSubmitting ? ( 849 + <LoaderCircle className="size-4 animate-spin" /> 850 + ) : null} 851 + {!nextBlockId 852 + ? t("publicRunner.submit") 853 + : t("publicRunner.continue")} 854 + {!isSubmitting ? ( 855 + <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"> 856 + <CornerDownLeft className="size-3" /> 857 + </span> 858 + ) : null} 859 + </Button> 860 + </div> 633 861 </div> 634 - </div> 635 - </Card> 862 + </Card> 636 863 </> 637 864 ); 638 865 }
+45 -11
components/response-export-actions.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEffect, useRef, useState } from "react"; 4 - import { ChevronDown, Download, FileSpreadsheet, LoaderCircle } from "lucide-react"; 4 + import { 5 + ChevronDown, 6 + Download, 7 + FileSpreadsheet, 8 + LoaderCircle, 9 + } from "lucide-react"; 5 10 6 11 import { useI18n } from "@/components/i18n-provider"; 7 12 import { Button } from "@/components/ui/button"; ··· 9 14 10 15 type ResponseExportFormat = "csv" | "xlsx"; 11 16 12 - function getFilenameFromDisposition(disposition: string | null, fallback: string) { 17 + function getFilenameFromDisposition( 18 + disposition: string | null, 19 + fallback: string, 20 + ) { 13 21 if (!disposition) { 14 22 return fallback; 15 23 } ··· 26 34 27 35 export function ResponseExportActions({ formId }: { formId: string }) { 28 36 const { t } = useI18n(); 29 - const [busyFormat, setBusyFormat] = useState<ResponseExportFormat | null>(null); 37 + const [busyFormat, setBusyFormat] = useState<ResponseExportFormat | null>( 38 + null, 39 + ); 30 40 const [isMenuOpen, setIsMenuOpen] = useState(false); 31 41 const [toasts, setToasts] = useState<ToastData[]>([]); 32 42 const menuRef = useRef<HTMLDivElement | null>(null); 33 43 34 - function showToast(message: string, variant: ToastData["variant"] = "success") { 44 + function showToast( 45 + message: string, 46 + variant: ToastData["variant"] = "success", 47 + ) { 35 48 const id = crypto.randomUUID(); 36 49 setToasts((current) => [...current, { id, message, variant }]); 37 50 } ··· 59 72 try { 60 73 setBusyFormat(format); 61 74 62 - const response = await fetch(`/api/forms/${formId}/export?format=${format}`); 75 + const response = await fetch( 76 + `/api/forms/${formId}/export?format=${format}`, 77 + ); 63 78 64 79 if (!response.ok) { 65 - const payload = (await response.json().catch(() => ({}))) as { error?: string }; 80 + const payload = (await response.json().catch(() => ({}))) as { 81 + error?: string; 82 + }; 66 83 throw new Error(payload.error ?? t("responseExport.error")); 67 84 } 68 85 ··· 82 99 URL.revokeObjectURL(url); 83 100 84 101 setIsMenuOpen(false); 85 - showToast(t("responseExport.downloaded", { format: format.toUpperCase() })); 102 + showToast( 103 + t("responseExport.downloaded", { format: format.toUpperCase() }), 104 + ); 86 105 } catch (error) { 87 - showToast(error instanceof Error ? error.message : t("responseExport.error"), "error"); 106 + showToast( 107 + error instanceof Error ? error.message : t("responseExport.error"), 108 + "error", 109 + ); 88 110 } finally { 89 111 setBusyFormat(null); 90 112 } ··· 100 122 aria-expanded={isMenuOpen} 101 123 aria-haspopup="menu" 102 124 > 103 - {busyFormat ? <LoaderCircle className="size-4 animate-spin" /> : <Download className="size-4" />} 125 + {busyFormat ? ( 126 + <LoaderCircle className="size-4 animate-spin" /> 127 + ) : ( 128 + <Download className="size-4" /> 129 + )} 104 130 {t("responseExport.export")} 105 131 <ChevronDown className="size-4 text-[var(--muted)]" /> 106 132 </Button> ··· 113 139 onClick={() => exportResponses("csv")} 114 140 disabled={busyFormat !== null} 115 141 > 116 - {busyFormat === "csv" ? <LoaderCircle className="size-4 animate-spin" /> : <Download className="size-4" />} 142 + {busyFormat === "csv" ? ( 143 + <LoaderCircle className="size-4 animate-spin" /> 144 + ) : ( 145 + <Download className="size-4" /> 146 + )} 117 147 CSV 118 148 </button> 119 149 <button ··· 122 152 onClick={() => exportResponses("xlsx")} 123 153 disabled={busyFormat !== null} 124 154 > 125 - {busyFormat === "xlsx" ? <LoaderCircle className="size-4 animate-spin" /> : <FileSpreadsheet className="size-4" />} 155 + {busyFormat === "xlsx" ? ( 156 + <LoaderCircle className="size-4 animate-spin" /> 157 + ) : ( 158 + <FileSpreadsheet className="size-4" /> 159 + )} 126 160 XLSX 127 161 </button> 128 162 </div>
+42 -14
components/settings-shell.tsx
··· 9 9 import { ProfileSettingsPanel } from "@/components/profile-settings-panel"; 10 10 import { ThemeSettingsPanel } from "@/components/theme-settings-panel"; 11 11 import { cn } from "@/lib/utils"; 12 - import type { OrganizationSettingsSummary, ProfileSettingsUserSummary } from "@/lib/form-types"; 12 + import type { 13 + OrganizationSettingsSummary, 14 + ProfileSettingsUserSummary, 15 + } from "@/lib/form-types"; 13 16 14 17 type SettingsSection = "profile" | "organizations" | "appearance"; 15 18 16 - const sectionMeta: Record<SettingsSection, { key: string; icon: typeof Building2 }> = { 19 + const sectionMeta: Record< 20 + SettingsSection, 21 + { key: string; icon: typeof Building2 } 22 + > = { 17 23 profile: { 18 24 key: "profile", 19 25 icon: UserRound, ··· 44 50 const searchParams = useSearchParams(); 45 51 const { t } = useI18n(); 46 52 const [selection, setSelection] = useState<SettingsSection>( 47 - initialSection === "organizations" || initialSection === "appearance" ? initialSection : "profile", 53 + initialSection === "organizations" || initialSection === "appearance" 54 + ? initialSection 55 + : "profile", 48 56 ); 49 57 50 58 return ( 51 59 <div className="space-y-6"> 52 60 <section className="flex flex-col gap-4 border-b border-[color:var(--line)] pb-5 lg:flex-row lg:items-end lg:justify-between"> 53 61 <div> 54 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("settings.shell.title")}</h1> 62 + <h1 className="font-display text-4xl text-[var(--ink)]"> 63 + {t("settings.shell.title")} 64 + </h1> 55 65 <p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 56 66 {t("settings.shell.description")} 57 67 </p> ··· 71 81 type="button" 72 82 onClick={() => { 73 83 setSelection(section); 74 - const nextParams = new URLSearchParams(searchParams.toString()); 84 + const nextParams = new URLSearchParams( 85 + searchParams.toString(), 86 + ); 75 87 nextParams.set("section", section); 76 - router.replace(`${pathname}?${nextParams.toString()}`, { scroll: false }); 88 + router.replace(`${pathname}?${nextParams.toString()}`, { 89 + scroll: false, 90 + }); 77 91 }} 78 92 className={cn( 79 93 "group flex w-full items-center gap-3 rounded-[16px] px-3 py-3 text-left transition", ··· 82 96 : "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--ink)]", 83 97 )} 84 98 > 85 - <div className={cn( 86 - "rounded-full p-1.5", 87 - selected ? "bg-[var(--accent-soft)] text-[var(--accent-ink)]" : "bg-[var(--bg-strong)] text-[var(--muted)]", 88 - )}> 99 + <div 100 + className={cn( 101 + "rounded-full p-1.5", 102 + selected 103 + ? "bg-[var(--accent-soft)] text-[var(--accent-ink)]" 104 + : "bg-[var(--bg-strong)] text-[var(--muted)]", 105 + )} 106 + > 89 107 <Icon className="size-3.5" /> 90 108 </div> 91 109 <div className="min-w-0 flex-1"> 92 - <p className="text-sm font-medium">{t(`settings.sections.${meta.key}.title`)}</p> 93 - <p className="mt-1 truncate text-xs opacity-80">{t(`settings.sections.${meta.key}.description`)}</p> 110 + <p className="text-sm font-medium"> 111 + {t(`settings.sections.${meta.key}.title`)} 112 + </p> 113 + <p className="mt-1 truncate text-xs opacity-80"> 114 + {t(`settings.sections.${meta.key}.description`)} 115 + </p> 94 116 </div> 95 117 </button> 96 118 ); ··· 99 121 100 122 <div className="min-w-0"> 101 123 {selection === "profile" ? ( 102 - <ProfileSettingsPanel user={profileUser} initialOrganizationId={initialOrganizationId} /> 124 + <ProfileSettingsPanel 125 + user={profileUser} 126 + initialOrganizationId={initialOrganizationId} 127 + /> 103 128 ) : selection === "organizations" ? ( 104 - <OrganizationSettingsPanel organizations={organizations} initialOrganizationId={initialOrganizationId} /> 129 + <OrganizationSettingsPanel 130 + organizations={organizations} 131 + initialOrganizationId={initialOrganizationId} 132 + /> 105 133 ) : ( 106 134 <ThemeSettingsPanel /> 107 135 )}
+23 -6
components/theme-provider.tsx
··· 34 34 return "light"; 35 35 } 36 36 37 - return resolveTheme(readStoredThemePreference(), window.matchMedia("(prefers-color-scheme: dark)").matches); 37 + return resolveTheme( 38 + readStoredThemePreference(), 39 + window.matchMedia("(prefers-color-scheme: dark)").matches, 40 + ); 38 41 } 39 42 40 43 export function ThemeProvider({ children }: { children: React.ReactNode }) { 41 - const [preference, setPreferenceState] = useState<ThemePreference>(getInitialPreference); 42 - const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>(getInitialResolvedTheme); 44 + const [preference, setPreferenceState] = 45 + useState<ThemePreference>(getInitialPreference); 46 + const [resolvedTheme, setResolvedTheme] = useState<ResolvedTheme>( 47 + getInitialResolvedTheme, 48 + ); 43 49 44 50 useEffect(() => { 45 51 const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); ··· 51 57 return; 52 58 } 53 59 54 - const nextResolved = applyResolvedTheme(document.documentElement, currentPreference, event.matches); 60 + const nextResolved = applyResolvedTheme( 61 + document.documentElement, 62 + currentPreference, 63 + event.matches, 64 + ); 55 65 setResolvedTheme(nextResolved); 56 66 }; 57 67 ··· 83 93 setPreferenceState(nextPreference); 84 94 persistThemePreference(nextPreference); 85 95 applyThemePreference(nextPreference); 86 - setResolvedTheme(resolveTheme(nextPreference, window.matchMedia("(prefers-color-scheme: dark)").matches)); 96 + setResolvedTheme( 97 + resolveTheme( 98 + nextPreference, 99 + window.matchMedia("(prefers-color-scheme: dark)").matches, 100 + ), 101 + ); 87 102 } 88 103 89 104 const value = useMemo( ··· 91 106 [preference, resolvedTheme], 92 107 ); 93 108 94 - return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>; 109 + return ( 110 + <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> 111 + ); 95 112 } 96 113 97 114 export function useThemePreference() {
+11 -4
components/theme-settings-panel.tsx
··· 32 32 return ( 33 33 <div> 34 34 <div> 35 - <h1 className="font-display text-4xl text-[var(--ink)]">{t("settings.appearance.title")}</h1> 35 + <h1 className="font-display text-4xl text-[var(--ink)]"> 36 + {t("settings.appearance.title")} 37 + </h1> 36 38 <p className="mt-3 max-w-2xl text-sm leading-6 text-[var(--muted)]"> 37 39 {t("settings.appearance.description")} 38 40 </p> ··· 61 63 <Icon className="size-5" /> 62 64 </span> 63 65 <div> 64 - <p className="font-semibold text-[var(--ink)]">{t(`settings.appearance.options.${option.value}.title`)}</p> 65 - <p className="mt-1 text-sm text-[var(--muted)]">{t(`settings.appearance.options.${option.value}.description`)}</p> 66 + <p className="font-semibold text-[var(--ink)]"> 67 + {t(`settings.appearance.options.${option.value}.title`)} 68 + </p> 69 + <p className="mt-1 text-sm text-[var(--muted)]"> 70 + {t( 71 + `settings.appearance.options.${option.value}.description`, 72 + )} 73 + </p> 66 74 </div> 67 75 </div> 68 76 </button> 69 77 ); 70 78 })} 71 79 </div> 72 - 73 80 </div> 74 81 ); 75 82 }
+4 -1
components/ui/badge.tsx
··· 2 2 3 3 import { cn } from "@/lib/utils"; 4 4 5 - export function Badge({ className, ...props }: HTMLAttributes<HTMLSpanElement>) { 5 + export function Badge({ 6 + className, 7 + ...props 8 + }: HTMLAttributes<HTMLSpanElement>) { 6 9 return ( 7 10 <span 8 11 className={cn(
+10 -2
components/ui/button.tsx
··· 32 32 ); 33 33 34 34 export interface ButtonProps 35 - extends React.ButtonHTMLAttributes<HTMLButtonElement>, 35 + extends 36 + React.ButtonHTMLAttributes<HTMLButtonElement>, 36 37 VariantProps<typeof buttonVariants> {} 37 38 38 39 const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( 39 40 ({ className, variant, size, type = "button", ...props }, ref) => { 40 - return <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} type={type} {...props} />; 41 + return ( 42 + <button 43 + className={cn(buttonVariants({ variant, size, className }))} 44 + ref={ref} 45 + type={type} 46 + {...props} 47 + /> 48 + ); 41 49 }, 42 50 ); 43 51 Button.displayName = "Button";
+63 -14
components/ui/calendar.tsx
··· 2 2 3 3 import * as React from "react"; 4 4 import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; 5 - import { DayPicker, type DayButton, getDefaultClassNames } from "react-day-picker"; 5 + import { 6 + DayPicker, 7 + type DayButton, 8 + getDefaultClassNames, 9 + } from "react-day-picker"; 6 10 7 11 import { Button, buttonVariants } from "@/components/ui/button"; 8 12 import { cn } from "@/lib/utils"; ··· 25 29 root: cn("w-fit", defaultClassNames.root), 26 30 months: cn("relative flex flex-col gap-4", defaultClassNames.months), 27 31 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), 32 + nav: cn( 33 + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", 34 + defaultClassNames.nav, 35 + ), 29 36 button_previous: cn( 30 37 buttonVariants({ variant: "ghost", size: "icon" }), 31 38 "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)]", ··· 36 43 "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 44 defaultClassNames.button_next, 38 45 ), 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), 46 + month_caption: cn( 47 + "flex h-9 w-full items-center justify-center px-10", 48 + defaultClassNames.month_caption, 49 + ), 50 + dropdowns: cn( 51 + "flex h-9 w-full items-center justify-center gap-2 text-sm font-semibold text-[var(--ink)]", 52 + defaultClassNames.dropdowns, 53 + ), 54 + dropdown_root: cn( 55 + "relative rounded-xl border border-[color:var(--line)] bg-[var(--surface)]", 56 + defaultClassNames.dropdown_root, 57 + ), 42 58 dropdown: cn("absolute inset-0 opacity-0", defaultClassNames.dropdown), 43 - caption_label: cn("text-sm font-semibold text-[var(--ink)]", defaultClassNames.caption_label), 59 + caption_label: cn( 60 + "text-sm font-semibold text-[var(--ink)]", 61 + defaultClassNames.caption_label, 62 + ), 44 63 table: "w-full border-collapse", 45 64 weekdays: cn("flex gap-1", defaultClassNames.weekdays), 46 65 weekday: cn( ··· 49 68 ), 50 69 week: cn("mt-1 flex w-full gap-1", defaultClassNames.week), 51 70 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), 71 + today: cn( 72 + "rounded-xl ring-1 ring-[color:var(--line-strong)]", 73 + defaultClassNames.today, 74 + ), 53 75 outside: cn("text-[var(--muted)]/45", defaultClassNames.outside), 54 - disabled: cn("pointer-events-none opacity-40", defaultClassNames.disabled), 76 + disabled: cn( 77 + "pointer-events-none opacity-40", 78 + defaultClassNames.disabled, 79 + ), 55 80 hidden: cn("invisible", defaultClassNames.hidden), 56 81 ...classNames, 57 82 }} 58 83 components={{ 59 84 Chevron: ({ className: iconClassName, orientation, ...iconProps }) => { 60 85 if (orientation === "left") { 61 - return <ChevronLeft className={cn("size-4", iconClassName)} {...iconProps} />; 86 + return ( 87 + <ChevronLeft 88 + className={cn("size-4", iconClassName)} 89 + {...iconProps} 90 + /> 91 + ); 62 92 } 63 93 64 94 if (orientation === "right") { 65 - return <ChevronRight className={cn("size-4", iconClassName)} {...iconProps} />; 95 + return ( 96 + <ChevronRight 97 + className={cn("size-4", iconClassName)} 98 + {...iconProps} 99 + /> 100 + ); 66 101 } 67 102 68 - return <ChevronDown className={cn("size-4", iconClassName)} {...iconProps} />; 103 + return ( 104 + <ChevronDown 105 + className={cn("size-4", iconClassName)} 106 + {...iconProps} 107 + /> 108 + ); 69 109 }, 70 110 DayButton: CalendarDayButton, 71 111 }} ··· 74 114 ); 75 115 } 76 116 77 - function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) { 117 + function CalendarDayButton({ 118 + className, 119 + day, 120 + modifiers, 121 + ...props 122 + }: React.ComponentProps<typeof DayButton>) { 78 123 const defaultClassNames = getDefaultClassNames(); 79 124 const ref = React.useRef<HTMLButtonElement>(null); 80 125 ··· 95 140 modifiers.selected 96 141 ? "bg-[var(--accent)] text-white hover:bg-[var(--accent)] hover:text-white" 97 142 : "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, 143 + modifiers.today && !modifiers.selected 144 + ? "border border-[color:var(--line-strong)]" 145 + : "border border-transparent", 146 + modifiers.outside 147 + ? "text-[var(--muted)]/45 hover:text-[var(--muted)]" 148 + : null, 100 149 modifiers.disabled ? "pointer-events-none opacity-40" : null, 101 150 defaultClassNames.day, 102 151 className,
+19 -18
components/ui/checkbox.tsx
··· 3 3 4 4 import { cn } from "@/lib/utils"; 5 5 6 - export const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>( 7 - ({ className, ...props }, ref) => { 8 - return ( 9 - <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> 10 - <input 11 - ref={ref} 12 - type="checkbox" 13 - className={cn( 14 - "peer size-5 cursor-pointer appearance-none rounded-[7px] border border-[color:var(--line)] bg-[var(--surface-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition-all duration-200 hover:border-[color:var(--line-strong)] hover:bg-[var(--surface)] checked:border-[var(--accent)] checked:bg-[var(--accent-soft)] checked:shadow-[var(--shadow-accent)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50", 15 - className, 16 - )} 17 - {...props} 18 - /> 19 - <Check className="pointer-events-none absolute size-3.5 text-[var(--accent)] opacity-0 transition-opacity duration-200 peer-checked:opacity-100" /> 20 - </span> 21 - ); 22 - }, 23 - ); 6 + export const Checkbox = React.forwardRef< 7 + HTMLInputElement, 8 + React.InputHTMLAttributes<HTMLInputElement> 9 + >(({ className, ...props }, ref) => { 10 + return ( 11 + <span className="relative inline-flex size-5 shrink-0 items-center justify-center"> 12 + <input 13 + ref={ref} 14 + type="checkbox" 15 + className={cn( 16 + "peer size-5 cursor-pointer appearance-none rounded-[7px] border border-[color:var(--line)] bg-[var(--surface-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition-all duration-200 hover:border-[color:var(--line-strong)] hover:bg-[var(--surface)] checked:border-[var(--accent)] checked:bg-[var(--accent-soft)] checked:shadow-[var(--shadow-accent)] focus-visible:ring-2 focus-visible:ring-[var(--accent-soft)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg)] disabled:cursor-not-allowed disabled:opacity-50", 17 + className, 18 + )} 19 + {...props} 20 + /> 21 + <Check className="pointer-events-none absolute size-3.5 text-[var(--accent)] opacity-0 transition-opacity duration-200 peer-checked:opacity-100" /> 22 + </span> 23 + ); 24 + }); 24 25 Checkbox.displayName = "Checkbox";
+8 -2
components/ui/confirm-dialog.tsx
··· 63 63 onClick={(event) => event.stopPropagation()} 64 64 > 65 65 <h2 className="font-display text-3xl text-[var(--ink)]">{title}</h2> 66 - <p className="mt-3 text-sm leading-6 text-[var(--muted)]">{description}</p> 66 + <p className="mt-3 text-sm leading-6 text-[var(--muted)]"> 67 + {description} 68 + </p> 67 69 {actions ?? ( 68 70 <div className="mt-6 flex flex-wrap justify-end gap-3"> 69 71 <Button variant="secondary" onClick={onClose} disabled={pending}> 70 72 {cancelLabel} 71 73 </Button> 72 - <Button variant={confirmVariant} onClick={onConfirm} disabled={pending}> 74 + <Button 75 + variant={confirmVariant} 76 + onClick={onConfirm} 77 + disabled={pending} 78 + > 73 79 {confirmLabel} 74 80 </Button> 75 81 </div>
+30 -6
components/ui/date-picker-input.tsx
··· 1 1 "use client"; 2 2 3 3 import { CalendarIcon } from "lucide-react"; 4 - import { useMemo, useState, type InputHTMLAttributes, type KeyboardEventHandler } from "react"; 4 + import { 5 + useMemo, 6 + useState, 7 + type InputHTMLAttributes, 8 + type KeyboardEventHandler, 9 + } from "react"; 5 10 6 11 import { Calendar } from "@/components/ui/calendar"; 7 12 import { Button } from "@/components/ui/button"; 8 13 import { Input } from "@/components/ui/input"; 9 - import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; 14 + import { 15 + Popover, 16 + PopoverContent, 17 + PopoverTrigger, 18 + } from "@/components/ui/popover"; 10 19 import { isValidDateAnswer } from "@/lib/blocks"; 11 20 import { cn } from "@/lib/utils"; 12 21 13 - type DatePickerInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & { 22 + type DatePickerInputProps = Omit< 23 + InputHTMLAttributes<HTMLInputElement>, 24 + "type" | "value" | "onChange" 25 + > & { 14 26 value: string; 15 27 onChange: (value: string) => void; 16 28 onKeyDown?: KeyboardEventHandler<HTMLInputElement>; ··· 33 45 return `${year}-${month}-${day}`; 34 46 } 35 47 36 - export function DatePickerInput({ className, onChange, onKeyDown, openCalendarLabel, value, ...props }: DatePickerInputProps) { 48 + export function DatePickerInput({ 49 + className, 50 + onChange, 51 + onKeyDown, 52 + openCalendarLabel, 53 + value, 54 + ...props 55 + }: DatePickerInputProps) { 37 56 const [open, setOpen] = useState(false); 38 57 const selectedDate = useMemo(() => parseDateValue(value), [value]); 39 - const [visibleMonth, setVisibleMonth] = useState<Date>(selectedDate ?? new Date()); 58 + const [visibleMonth, setVisibleMonth] = useState<Date>( 59 + selectedDate ?? new Date(), 60 + ); 40 61 41 62 return ( 42 63 <div className="relative"> ··· 50 71 value={value} 51 72 onChange={(event) => onChange(event.target.value)} 52 73 onKeyDown={onKeyDown} 53 - className={cn("h-12 pr-14 text-base tabular-nums placeholder:text-[var(--muted)]/65", className)} 74 + className={cn( 75 + "h-12 pr-14 text-base tabular-nums placeholder:text-[var(--muted)]/65", 76 + className, 77 + )} 54 78 /> 55 79 <Popover open={open} onOpenChange={setOpen}> 56 80 <PopoverTrigger asChild>
+15 -14
components/ui/input.tsx
··· 2 2 3 3 import { cn } from "@/lib/utils"; 4 4 5 - export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>( 6 - ({ className, ...props }, ref) => { 7 - return ( 8 - <input 9 - ref={ref} 10 - className={cn( 11 - "flex h-11 w-full rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition focus:border-[color:var(--line-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]", 12 - className, 13 - )} 14 - {...props} 15 - /> 16 - ); 17 - }, 18 - ); 5 + export const Input = React.forwardRef< 6 + HTMLInputElement, 7 + React.InputHTMLAttributes<HTMLInputElement> 8 + >(({ className, ...props }, ref) => { 9 + return ( 10 + <input 11 + ref={ref} 12 + className={cn( 13 + "flex h-11 w-full rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition focus:border-[color:var(--line-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]", 14 + className, 15 + )} 16 + {...props} 17 + /> 18 + ); 19 + }); 19 20 Input.displayName = "Input";
+59 -14
components/ui/select.tsx
··· 6 6 7 7 import { cn } from "@/lib/utils"; 8 8 9 - function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) { 9 + function Select({ 10 + ...props 11 + }: React.ComponentProps<typeof SelectPrimitive.Root>) { 10 12 return <SelectPrimitive.Root data-slot="select" {...props} />; 11 13 } 12 14 13 - function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) { 15 + function SelectGroup({ 16 + ...props 17 + }: React.ComponentProps<typeof SelectPrimitive.Group>) { 14 18 return <SelectPrimitive.Group data-slot="select-group" {...props} />; 15 19 } 16 20 17 - function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) { 21 + function SelectValue({ 22 + ...props 23 + }: React.ComponentProps<typeof SelectPrimitive.Value>) { 18 24 return <SelectPrimitive.Value data-slot="select-value" {...props} />; 19 25 } 20 26 21 - function SelectTrigger({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger>) { 27 + function SelectTrigger({ 28 + className, 29 + children, 30 + ...props 31 + }: React.ComponentProps<typeof SelectPrimitive.Trigger>) { 22 32 return ( 23 33 <SelectPrimitive.Trigger 24 34 data-slot="select-trigger" ··· 36 46 ); 37 47 } 38 48 39 - function SelectContent({ className, children, position = "popper", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) { 49 + function SelectContent({ 50 + className, 51 + children, 52 + position = "popper", 53 + ...props 54 + }: React.ComponentProps<typeof SelectPrimitive.Content>) { 40 55 return ( 41 56 <SelectPrimitive.Portal> 42 57 <SelectPrimitive.Content ··· 66 81 ); 67 82 } 68 83 69 - function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) { 84 + function SelectLabel({ 85 + className, 86 + ...props 87 + }: React.ComponentProps<typeof SelectPrimitive.Label>) { 70 88 return ( 71 89 <SelectPrimitive.Label 72 90 data-slot="select-label" 73 - className={cn("px-2 py-1.5 text-xs font-semibold text-[var(--muted)]", className)} 91 + className={cn( 92 + "px-2 py-1.5 text-xs font-semibold text-[var(--muted)]", 93 + className, 94 + )} 74 95 {...props} 75 96 /> 76 97 ); 77 98 } 78 99 79 - function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) { 100 + function SelectItem({ 101 + className, 102 + children, 103 + ...props 104 + }: React.ComponentProps<typeof SelectPrimitive.Item>) { 80 105 return ( 81 106 <SelectPrimitive.Item 82 107 data-slot="select-item" ··· 96 121 ); 97 122 } 98 123 99 - function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) { 100 - return <SelectPrimitive.Separator className={cn("-mx-1 my-1 h-px bg-[var(--line)]", className)} {...props} />; 124 + function SelectSeparator({ 125 + className, 126 + ...props 127 + }: React.ComponentProps<typeof SelectPrimitive.Separator>) { 128 + return ( 129 + <SelectPrimitive.Separator 130 + className={cn("-mx-1 my-1 h-px bg-[var(--line)]", className)} 131 + {...props} 132 + /> 133 + ); 101 134 } 102 135 103 - function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { 136 + function SelectScrollUpButton({ 137 + className, 138 + ...props 139 + }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { 104 140 return ( 105 141 <SelectPrimitive.ScrollUpButton 106 - className={cn("flex cursor-default items-center justify-center py-1 text-[var(--muted)]", className)} 142 + className={cn( 143 + "flex cursor-default items-center justify-center py-1 text-[var(--muted)]", 144 + className, 145 + )} 107 146 {...props} 108 147 > 109 148 <ChevronUp className="size-4" /> ··· 111 150 ); 112 151 } 113 152 114 - function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { 153 + function SelectScrollDownButton({ 154 + className, 155 + ...props 156 + }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { 115 157 return ( 116 158 <SelectPrimitive.ScrollDownButton 117 - className={cn("flex cursor-default items-center justify-center py-1 text-[var(--muted)]", className)} 159 + className={cn( 160 + "flex cursor-default items-center justify-center py-1 text-[var(--muted)]", 161 + className, 162 + )} 118 163 {...props} 119 164 > 120 165 <ChevronDown className="size-4" />
+15 -14
components/ui/textarea.tsx
··· 2 2 3 3 import { cn } from "@/lib/utils"; 4 4 5 - export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>( 6 - ({ className, ...props }, ref) => { 7 - return ( 8 - <textarea 9 - ref={ref} 10 - className={cn( 11 - "flex min-h-[120px] w-full rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition focus:border-[color:var(--line-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]", 12 - className, 13 - )} 14 - {...props} 15 - /> 16 - ); 17 - }, 18 - ); 5 + export const Textarea = React.forwardRef< 6 + HTMLTextAreaElement, 7 + React.TextareaHTMLAttributes<HTMLTextAreaElement> 8 + >(({ className, ...props }, ref) => { 9 + return ( 10 + <textarea 11 + ref={ref} 12 + className={cn( 13 + "flex min-h-[120px] w-full rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] px-4 py-3 text-sm text-[var(--ink)] shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] outline-none transition focus:border-[color:var(--line-strong)] focus:ring-2 focus:ring-[var(--accent-soft)]", 14 + className, 15 + )} 16 + {...props} 17 + /> 18 + ); 19 + }); 19 20 Textarea.displayName = "Textarea";
+9 -4
components/ui/toast.tsx
··· 22 22 onDismiss: (id: string) => void; 23 23 }) { 24 24 useEffect(() => { 25 - const timeout = window.setTimeout(() => { 26 - onDismiss(toast.id); 27 - }, toast.duration ?? (toast.variant === "error" ? 5000 : 2600)); 25 + const timeout = window.setTimeout( 26 + () => { 27 + onDismiss(toast.id); 28 + }, 29 + toast.duration ?? (toast.variant === "error" ? 5000 : 2600), 30 + ); 28 31 29 32 return () => window.clearTimeout(timeout); 30 33 }, [onDismiss, toast.duration, toast.id, toast.variant]); ··· 55 58 onClick={() => onDismiss(toast.id)} 56 59 className={cn( 57 60 "rounded-full p-1 opacity-60 transition hover:opacity-100", 58 - toast.variant === "error" ? "hover:bg-[var(--danger-soft)]" : "hover:bg-[var(--accent-soft)]", 61 + toast.variant === "error" 62 + ? "hover:bg-[var(--danger-soft)]" 63 + : "hover:bg-[var(--accent-soft)]", 59 64 )} 60 65 aria-label={t("ui.dismissNotification")} 61 66 >
+14 -2
components/user-avatar.tsx
··· 2 2 3 3 import { useState } from "react"; 4 4 5 - import { getUserDisplayName, getUserImage, getUserInitials, type UserIdentityLike } from "@/lib/user-identity"; 5 + import { 6 + getUserDisplayName, 7 + getUserImage, 8 + getUserInitials, 9 + type UserIdentityLike, 10 + } from "@/lib/user-identity"; 6 11 import { cn } from "@/lib/utils"; 7 12 8 13 export function UserAvatar({ ··· 39 44 onError={() => setFailedImage(image)} 40 45 /> 41 46 ) : ( 42 - <span className={cn("text-sm font-semibold uppercase tracking-[0.08em]", initialsClassName)}>{initials}</span> 47 + <span 48 + className={cn( 49 + "text-sm font-semibold uppercase tracking-[0.08em]", 50 + initialsClassName, 51 + )} 52 + > 53 + {initials} 54 + </span> 43 55 )} 44 56 </span> 45 57 );
+10 -3
components/workspace-switcher.tsx
··· 26 26 const [isPending, startTransition] = useTransition(); 27 27 const { t } = useI18n(); 28 28 const selectKey = options 29 - .map((option) => `${option.value}:${option.label}:${option.kind === "organization" && option.isOwner ? "owner" : "member"}`) 29 + .map( 30 + (option) => 31 + `${option.value}:${option.label}:${option.kind === "organization" && option.isOwner ? "owner" : "member"}`, 32 + ) 30 33 .join("|"); 31 34 32 35 return ( ··· 53 56 <SelectContent align="end"> 54 57 {options.map((option) => ( 55 58 <SelectItem key={option.value} value={option.value}> 56 - {option.kind === "personal" ? t("workspace.personal") : option.label} 57 - {option.kind === "organization" && option.isOwner ? ` · ${t("workspace.ownerSuffix")}` : ""} 59 + {option.kind === "personal" 60 + ? t("workspace.personal") 61 + : option.label} 62 + {option.kind === "organization" && option.isOwner 63 + ? ` · ${t("workspace.ownerSuffix")}` 64 + : ""} 58 65 </SelectItem> 59 66 ))} 60 67 </SelectContent>
+4
docs/deployment.md
··· 57 57 Use these values during local development: 58 58 59 59 **Authorized JavaScript origins** 60 + 60 61 - `http://localhost:3000` 61 62 62 63 **Authorized redirect URIs** 64 + 63 65 - `http://localhost:3000/api/auth/callback/google` 64 66 65 67 If these values are missing or incorrect, Google sign-in will fail. ··· 71 73 Example: 72 74 73 75 **Authorized JavaScript origins** 76 + 74 77 - `https://your-domain.example` 75 78 76 79 **Authorized redirect URIs** 80 + 77 81 - `https://your-domain.example/api/auth/callback/google` 78 82 79 83 Set `NEXTAUTH_URL` to the same public base URL.
+1
docs/development.md
··· 90 90 ``` 91 91 92 92 Notes: 93 + 93 94 - `bun run build` also validates translation resources before the Next.js build. 94 95 - There is currently no separate test suite command in `package.json`. 95 96
+12 -3
lib/api.ts
··· 5 5 6 6 export function handleRouteError(error: unknown) { 7 7 if (error instanceof AppError) { 8 - return NextResponse.json({ error: error.message }, { status: error.status }); 8 + return NextResponse.json( 9 + { error: error.message }, 10 + { status: error.status }, 11 + ); 9 12 } 10 13 11 14 if (error instanceof ZodError) { 12 - return NextResponse.json({ error: error.issues[0]?.message ?? "Invalid request payload." }, { status: 422 }); 15 + return NextResponse.json( 16 + { error: error.issues[0]?.message ?? "Invalid request payload." }, 17 + { status: 422 }, 18 + ); 13 19 } 14 20 15 21 console.error(error); 16 - return NextResponse.json({ error: "Unexpected server error." }, { status: 500 }); 22 + return NextResponse.json( 23 + { error: "Unexpected server error." }, 24 + { status: 500 }, 25 + ); 17 26 }
+8 -4
lib/auth.ts
··· 51 51 await syncGoogleProfileFields(user.id, { 52 52 name: typeof profile?.name === "string" ? profile.name : null, 53 53 given_name: 54 - profile && typeof (profile as { given_name?: unknown }).given_name === "string" 54 + profile && 55 + typeof (profile as { given_name?: unknown }).given_name === "string" 55 56 ? ((profile as { given_name?: string }).given_name ?? null) 56 57 : null, 57 58 family_name: 58 - profile && typeof (profile as { family_name?: unknown }).family_name === "string" 59 + profile && 60 + typeof (profile as { family_name?: unknown }).family_name === "string" 59 61 ? ((profile as { family_name?: string }).family_name ?? null) 60 62 : null, 61 63 picture: 62 - profile && typeof (profile as { picture?: unknown }).picture === "string" 64 + profile && 65 + typeof (profile as { picture?: unknown }).picture === "string" 63 66 ? ((profile as { picture?: string }).picture ?? null) 64 67 : null, 65 68 locale: 66 - profile && typeof (profile as { locale?: unknown }).locale === "string" 69 + profile && 70 + typeof (profile as { locale?: unknown }).locale === "string" 67 71 ? normalizeLocale((profile as { locale?: string }).locale ?? null) 68 72 : null, 69 73 });
+110 -26
lib/blocks.ts
··· 1 - import type { FormBlock, FormBlockType as PrismaFormBlockType } from "@prisma/client"; 1 + import type { 2 + FormBlock, 3 + FormBlockType as PrismaFormBlockType, 4 + } from "@prisma/client"; 2 5 import { z } from "zod"; 3 6 4 7 import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; ··· 86 89 }; 87 90 88 91 function getDefaultBlockCopy(locale: AppLocale = DEFAULT_LOCALE) { 89 - return defaultBlockCopyByLocale[locale] ?? defaultBlockCopyByLocale[DEFAULT_LOCALE]; 92 + return ( 93 + defaultBlockCopyByLocale[locale] ?? defaultBlockCopyByLocale[DEFAULT_LOCALE] 94 + ); 90 95 } 91 96 92 97 function normalizeRegexPattern(value: unknown) { ··· 188 193 export const branchRuleSchema = rawBranchRuleSchema; 189 194 190 195 const branchRulesSchema = z.array(branchRuleSchema).max(50).default([]); 191 - const defaultNextBlockIdSchema = z.string().trim().min(1).nullable().default(null); 196 + const defaultNextBlockIdSchema = z 197 + .string() 198 + .trim() 199 + .min(1) 200 + .nullable() 201 + .default(null); 192 202 193 203 const textConfigSchema = z.object({ 194 204 body: z.string().max(2400).default(defaultBlockCopyByLocale.en.textBody), 195 205 }); 196 206 197 207 const shortTextConfigSchema = z.object({ 198 - placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.shortPlaceholder), 208 + placeholder: z 209 + .string() 210 + .max(120) 211 + .default(defaultBlockCopyByLocale.en.shortPlaceholder), 199 212 validationRegex: regexPatternSchema.default(null), 200 213 branchRules: branchRulesSchema, 201 214 defaultNextBlockId: defaultNextBlockIdSchema, 202 215 }); 203 216 204 217 const longTextConfigSchema = z.object({ 205 - placeholder: z.string().max(240).default(defaultBlockCopyByLocale.en.longPlaceholder), 218 + placeholder: z 219 + .string() 220 + .max(240) 221 + .default(defaultBlockCopyByLocale.en.longPlaceholder), 206 222 validationRegex: regexPatternSchema.default(null), 207 223 branchRules: branchRulesSchema, 208 224 defaultNextBlockId: defaultNextBlockIdSchema, ··· 210 226 211 227 const numberConfigSchema = z 212 228 .object({ 213 - placeholder: z.string().max(120).default(defaultBlockCopyByLocale.en.numberPlaceholder), 229 + placeholder: z 230 + .string() 231 + .max(120) 232 + .default(defaultBlockCopyByLocale.en.numberPlaceholder), 214 233 allowFloat: z.boolean().default(false), 215 234 min: numericLimitSchema.default(null), 216 235 max: numericLimitSchema.default(null), ··· 246 265 }); 247 266 248 267 const linkConfigSchema = z.object({ 249 - placeholder: z.string().max(2048).default(defaultBlockCopyByLocale.en.linkPlaceholder), 268 + placeholder: z 269 + .string() 270 + .max(2048) 271 + .default(defaultBlockCopyByLocale.en.linkPlaceholder), 250 272 branchRules: branchRulesSchema, 251 273 defaultNextBlockId: defaultNextBlockIdSchema, 252 274 }); 253 275 254 276 const agreementConfigSchema = z.object({ 255 - label: z.string().trim().max(160).default(defaultBlockCopyByLocale.en.agreementLabel), 277 + label: z 278 + .string() 279 + .trim() 280 + .max(160) 281 + .default(defaultBlockCopyByLocale.en.agreementLabel), 256 282 branchRules: branchRulesSchema, 257 283 defaultNextBlockId: defaultNextBlockIdSchema, 258 284 }); ··· 263 289 }); 264 290 265 291 const optionListSchema = z.object({ 266 - options: z.array(z.string().min(1).max(120)).min(2).max(10).default(defaultBlockCopyByLocale.en.options), 292 + options: z 293 + .array(z.string().min(1).max(120)) 294 + .min(2) 295 + .max(10) 296 + .default(defaultBlockCopyByLocale.en.options), 267 297 branchRules: branchRulesSchema, 268 298 defaultNextBlockId: defaultNextBlockIdSchema, 269 299 }); 270 300 271 - export type AgreementAnswerValue = (typeof AGREEMENT_ANSWER_VALUES)[keyof typeof AGREEMENT_ANSWER_VALUES]; 301 + export type AgreementAnswerValue = 302 + (typeof AGREEMENT_ANSWER_VALUES)[keyof typeof AGREEMENT_ANSWER_VALUES]; 272 303 export type BranchOperator = z.infer<typeof branchOperatorSchema>; 273 304 export type BranchRule = z.infer<typeof branchRuleSchema>; 274 305 export type TextBlockConfig = z.infer<typeof textConfigSchema>; ··· 299 330 } 300 331 301 332 export function isTextAnswerBlock(type: PrismaFormBlockType) { 302 - return type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT; 333 + return ( 334 + type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT 335 + ); 303 336 } 304 337 305 338 export function isChoiceBlock(type: PrismaFormBlockType) { 306 - return type === FORM_BLOCK_TYPES.SINGLE_CHOICE || type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE; 339 + return ( 340 + type === FORM_BLOCK_TYPES.SINGLE_CHOICE || 341 + type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE 342 + ); 307 343 } 308 344 309 345 export function getTextValidationPattern(config: TextAnswerBlockConfig) { 310 346 return config.validationRegex ?? null; 311 347 } 312 348 313 - export function isAgreementAnswerValue(value: string): value is AgreementAnswerValue { 314 - return value === AGREEMENT_ANSWER_VALUES.AGREED || value === AGREEMENT_ANSWER_VALUES.NOT_AGREED; 349 + export function isAgreementAnswerValue( 350 + value: string, 351 + ): value is AgreementAnswerValue { 352 + return ( 353 + value === AGREEMENT_ANSWER_VALUES.AGREED || 354 + value === AGREEMENT_ANSWER_VALUES.NOT_AGREED 355 + ); 315 356 } 316 357 317 358 export function parseNumericAnswer(value: string) { ··· 340 381 } 341 382 342 383 const date = new Date(`${value}T00:00:00.000Z`); 343 - return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value; 384 + return ( 385 + !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value 386 + ); 344 387 } 345 388 346 - export function getSupportedBranchOperators(type: PrismaFormBlockType): BranchOperator[] { 389 + export function getSupportedBranchOperators( 390 + type: PrismaFormBlockType, 391 + ): BranchOperator[] { 347 392 if (type === FORM_BLOCK_TYPES.TEXT) { 348 393 return []; 349 394 } 350 395 351 - const common: BranchOperator[] = ["equals", "not_equals", "is_empty", "is_not_empty"]; 396 + const common: BranchOperator[] = [ 397 + "equals", 398 + "not_equals", 399 + "is_empty", 400 + "is_not_empty", 401 + ]; 352 402 353 - if (type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT || type === FORM_BLOCK_TYPES.LINK) { 403 + if ( 404 + type === FORM_BLOCK_TYPES.SHORT_TEXT || 405 + type === FORM_BLOCK_TYPES.LONG_TEXT || 406 + type === FORM_BLOCK_TYPES.LINK 407 + ) { 354 408 return [...common, "contains"]; 355 409 } 356 410 ··· 365 419 return common; 366 420 } 367 421 368 - export function getVisibleBranchOperators(type: PrismaFormBlockType): BranchOperator[] { 422 + export function getVisibleBranchOperators( 423 + type: PrismaFormBlockType, 424 + ): BranchOperator[] { 369 425 if (type === FORM_BLOCK_TYPES.TEXT) { 370 426 return []; 371 427 } 372 428 373 - if (type === FORM_BLOCK_TYPES.SHORT_TEXT || type === FORM_BLOCK_TYPES.LONG_TEXT || type === FORM_BLOCK_TYPES.LINK) { 429 + if ( 430 + type === FORM_BLOCK_TYPES.SHORT_TEXT || 431 + type === FORM_BLOCK_TYPES.LONG_TEXT || 432 + type === FORM_BLOCK_TYPES.LINK 433 + ) { 374 434 return ["is_empty", "is_not_empty", "contains"]; 375 435 } 376 436 ··· 383 443 } 384 444 385 445 if (type === FORM_BLOCK_TYPES.NUMBER || type === FORM_BLOCK_TYPES.DATE) { 386 - return ["equals", "not_equals", "gt", "gte", "lt", "lte", "is_empty", "is_not_empty"]; 446 + return [ 447 + "equals", 448 + "not_equals", 449 + "gt", 450 + "gte", 451 + "lt", 452 + "lte", 453 + "is_empty", 454 + "is_not_empty", 455 + ]; 387 456 } 388 457 389 458 if (type === FORM_BLOCK_TYPES.AGREEMENT) { ··· 397 466 return operator !== "is_empty" && operator !== "is_not_empty"; 398 467 } 399 468 400 - export function parseBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig { 469 + export function parseBlockConfig( 470 + type: PrismaFormBlockType, 471 + value: unknown, 472 + ): BlockConfig { 401 473 switch (type) { 402 474 case FORM_BLOCK_TYPES.TEXT: 403 475 return textConfigSchema.parse(value ?? {}); ··· 421 493 } 422 494 } 423 495 424 - export function getDefaultBlockConfig(type: PrismaFormBlockType, locale: AppLocale = DEFAULT_LOCALE): BlockConfig { 496 + export function getDefaultBlockConfig( 497 + type: PrismaFormBlockType, 498 + locale: AppLocale = DEFAULT_LOCALE, 499 + ): BlockConfig { 425 500 const copy = getDefaultBlockCopy(locale); 426 501 427 502 switch (type) { 428 503 case FORM_BLOCK_TYPES.TEXT: 429 504 return textConfigSchema.parse({ body: copy.textBody }); 430 505 case FORM_BLOCK_TYPES.SHORT_TEXT: 431 - return shortTextConfigSchema.parse({ placeholder: copy.shortPlaceholder }); 506 + return shortTextConfigSchema.parse({ 507 + placeholder: copy.shortPlaceholder, 508 + }); 432 509 case FORM_BLOCK_TYPES.LONG_TEXT: 433 510 return longTextConfigSchema.parse({ placeholder: copy.longPlaceholder }); 434 511 case FORM_BLOCK_TYPES.SINGLE_CHOICE: ··· 462 539 }; 463 540 } 464 541 465 - export function getBlockPreview(block: Pick<FormBlock, "type" | "title" | "description" | "config">, fallbackLabel?: string) { 542 + export function getBlockPreview( 543 + block: Pick<FormBlock, "type" | "title" | "description" | "config">, 544 + fallbackLabel?: string, 545 + ) { 466 546 const config = parseBlockConfig(block.type, block.config); 467 547 468 548 if (block.title.trim()) { ··· 488 568 return config.options.join(" • "); 489 569 } 490 570 491 - if (block.type === FORM_BLOCK_TYPES.AGREEMENT && "label" in config && config.label.trim()) { 571 + if ( 572 + block.type === FORM_BLOCK_TYPES.AGREEMENT && 573 + "label" in config && 574 + config.label.trim() 575 + ) { 492 576 return config.label; 493 577 } 494 578
+163 -41
lib/branching.ts
··· 55 55 return left.localeCompare(right); 56 56 } 57 57 58 - export function getBlockDisplayLabel(block: Pick<SerializedBlock, "position" | "title" | "type">) { 58 + export function getBlockDisplayLabel( 59 + block: Pick<SerializedBlock, "position" | "title" | "type">, 60 + ) { 59 61 const title = block.title.trim(); 60 - return title || `${isQuestionBlock(block.type) ? "Question" : "Block"} ${block.position + 1}`; 62 + return ( 63 + title || 64 + `${isQuestionBlock(block.type) ? "Question" : "Block"} ${block.position + 1}` 65 + ); 61 66 } 62 67 63 - export function getBranchValidationIssueI18n(issue: BranchValidationIssue, blocks: SerializedBlock[]): BranchValidationIssueI18n { 68 + export function getBranchValidationIssueI18n( 69 + issue: BranchValidationIssue, 70 + blocks: SerializedBlock[], 71 + ): BranchValidationIssueI18n { 64 72 const sourceBlock = blocks.find((block) => block.id === issue.blockId); 65 - const targetBlock = issue.targetBlockId ? blocks.find((block) => block.id === issue.targetBlockId) : null; 66 - const blockLabel = sourceBlock ? getBlockDisplayLabel(sourceBlock) : issue.blockId; 73 + const targetBlock = issue.targetBlockId 74 + ? blocks.find((block) => block.id === issue.targetBlockId) 75 + : null; 76 + const blockLabel = sourceBlock 77 + ? getBlockDisplayLabel(sourceBlock) 78 + : issue.blockId; 67 79 const targetLabel = targetBlock ? getBlockDisplayLabel(targetBlock) : ""; 68 80 69 81 return { ··· 76 88 }; 77 89 } 78 90 79 - export function normalizeBranchRuleOperand(block: SerializedBlock, rule: BranchRule) { 91 + export function normalizeBranchRuleOperand( 92 + block: SerializedBlock, 93 + rule: BranchRule, 94 + ) { 80 95 if (!branchOperatorNeedsValue(rule.operator)) { 81 96 return null; 82 97 } ··· 116 131 return null; 117 132 } 118 133 119 - function normalizeComparableAnswer(block: SerializedBlock, answer: string | string[] | undefined) { 134 + function normalizeComparableAnswer( 135 + block: SerializedBlock, 136 + answer: string | string[] | undefined, 137 + ) { 120 138 if (!isQuestionBlock(block.type)) { 121 139 return undefined; 122 140 } ··· 127 145 } 128 146 129 147 const options = (block.config as ChoiceBlockConfig).options; 130 - const normalized = [...new Set(answer.map((value) => value.trim()).filter((value) => options.includes(value)))]; 148 + const normalized = [ 149 + ...new Set( 150 + answer 151 + .map((value) => value.trim()) 152 + .filter((value) => options.includes(value)), 153 + ), 154 + ]; 131 155 return normalized.length ? normalized : undefined; 132 156 } 133 157 ··· 169 193 return undefined; 170 194 } 171 195 172 - function isRuleSupportedForBlock(block: SerializedBlock, operator: BranchOperator) { 196 + function isRuleSupportedForBlock( 197 + block: SerializedBlock, 198 + operator: BranchOperator, 199 + ) { 173 200 return getSupportedBranchOperators(block.type).includes(operator); 174 201 } 175 202 176 - function isValidForwardTarget(blocks: SerializedBlock[], sourceIndex: number, targetBlockId: string) { 203 + function isValidForwardTarget( 204 + blocks: SerializedBlock[], 205 + sourceIndex: number, 206 + targetBlockId: string, 207 + ) { 177 208 const targetIndex = blocks.findIndex((block) => block.id === targetBlockId); 178 209 return targetIndex > sourceIndex; 179 210 } ··· 185 216 operand: string | null, 186 217 ) { 187 218 const normalizedAnswer = normalizeComparableAnswer(block, answer); 188 - const isEmpty = typeof normalizedAnswer === "undefined" || (Array.isArray(normalizedAnswer) && normalizedAnswer.length === 0); 219 + const isEmpty = 220 + typeof normalizedAnswer === "undefined" || 221 + (Array.isArray(normalizedAnswer) && normalizedAnswer.length === 0); 189 222 190 223 switch (operator) { 191 224 case "is_empty": ··· 197 230 return false; 198 231 } 199 232 200 - return Array.isArray(normalizedAnswer) ? normalizedAnswer.includes(operand) : normalizedAnswer === operand; 233 + return Array.isArray(normalizedAnswer) 234 + ? normalizedAnswer.includes(operand) 235 + : normalizedAnswer === operand; 201 236 case "not_equals": 202 237 if (!operand || typeof normalizedAnswer === "undefined") { 203 238 return false; 204 239 } 205 240 206 - return Array.isArray(normalizedAnswer) ? !normalizedAnswer.includes(operand) : normalizedAnswer !== operand; 241 + return Array.isArray(normalizedAnswer) 242 + ? !normalizedAnswer.includes(operand) 243 + : normalizedAnswer !== operand; 207 244 case "contains": 208 - return typeof normalizedAnswer === "string" && typeof operand === "string" ? normalizedAnswer.includes(operand) : false; 245 + return typeof normalizedAnswer === "string" && typeof operand === "string" 246 + ? normalizedAnswer.includes(operand) 247 + : false; 209 248 case "contains_any": 210 - return Array.isArray(normalizedAnswer) && typeof operand === "string" ? normalizedAnswer.includes(operand) : false; 249 + return Array.isArray(normalizedAnswer) && typeof operand === "string" 250 + ? normalizedAnswer.includes(operand) 251 + : false; 211 252 case "gt": 212 253 case "gte": 213 254 case "lt": ··· 246 287 } 247 288 } 248 289 249 - export function branchRuleMatches(block: SerializedBlock, answer: string | string[] | undefined, rule: BranchRule) { 290 + export function branchRuleMatches( 291 + block: SerializedBlock, 292 + answer: string | string[] | undefined, 293 + rule: BranchRule, 294 + ) { 250 295 const operand = normalizeBranchRuleOperand(block, rule); 251 296 return compareRuleAgainstAnswer(block, rule.operator, answer, operand); 252 297 } ··· 283 328 } 284 329 } 285 330 286 - const configuredDefaultNextBlockId = getDefaultNextBlockId(currentBlock.config); 331 + const configuredDefaultNextBlockId = getDefaultNextBlockId( 332 + currentBlock.config, 333 + ); 287 334 288 335 if (configuredDefaultNextBlockId) { 289 - return isValidForwardTarget(blocks, currentIndex, configuredDefaultNextBlockId) 336 + return isValidForwardTarget( 337 + blocks, 338 + currentIndex, 339 + configuredDefaultNextBlockId, 340 + ) 290 341 ? configuredDefaultNextBlockId 291 342 : linearNextBlockId; 292 343 } ··· 305 356 return true; 306 357 } 307 358 308 - const supportedRules = rules.filter((rule) => isRuleSupportedForBlock(block, rule.operator)); 359 + const supportedRules = rules.filter((rule) => 360 + isRuleSupportedForBlock(block, rule.operator), 361 + ); 309 362 310 - if (supportedRules.some((rule) => rule.operator === "is_empty") && supportedRules.some((rule) => rule.operator === "is_not_empty")) { 363 + if ( 364 + supportedRules.some((rule) => rule.operator === "is_empty") && 365 + supportedRules.some((rule) => rule.operator === "is_not_empty") 366 + ) { 311 367 return false; 312 368 } 313 369 314 - if (block.type === "SHORT_TEXT" || block.type === "LONG_TEXT" || block.type === "NUMBER" || block.type === "LINK" || block.type === "DATE") { 370 + if ( 371 + block.type === "SHORT_TEXT" || 372 + block.type === "LONG_TEXT" || 373 + block.type === "NUMBER" || 374 + block.type === "LINK" || 375 + block.type === "DATE" 376 + ) { 315 377 return !supportedRules.some((rule) => rule.operator === "is_not_empty"); 316 378 } 317 379 ··· 339 401 .filter((value): value is string => Boolean(value)), 340 402 ); 341 403 342 - return !block.required || !explicitEqualsCoverage.has(AGREEMENT_ANSWER_VALUES.AGREED); 404 + return ( 405 + !block.required || 406 + !explicitEqualsCoverage.has(AGREEMENT_ANSWER_VALUES.AGREED) 407 + ); 343 408 } 344 409 345 410 return true; 346 411 } 347 412 348 - function getPossibleNextBlockIds(blocks: SerializedBlock[], block: SerializedBlock) { 413 + function getPossibleNextBlockIds( 414 + blocks: SerializedBlock[], 415 + block: SerializedBlock, 416 + ) { 349 417 const blockIndex = blocks.findIndex((candidate) => candidate.id === block.id); 350 418 351 419 if (blockIndex < 0) { ··· 365 433 } 366 434 367 435 for (const rule of getBranchRules(block.config)) { 368 - if (isRuleSupportedForBlock(block, rule.operator) && isValidForwardTarget(blocks, blockIndex, rule.targetBlockId)) { 436 + if ( 437 + isRuleSupportedForBlock(block, rule.operator) && 438 + isValidForwardTarget(blocks, blockIndex, rule.targetBlockId) 439 + ) { 369 440 nextIds.add(rule.targetBlockId); 370 441 } 371 442 } ··· 373 444 const configuredDefaultNextBlockId = getDefaultNextBlockId(block.config); 374 445 375 446 if (configuredDefaultNextBlockId) { 376 - if (isValidForwardTarget(blocks, blockIndex, configuredDefaultNextBlockId)) { 447 + if ( 448 + isValidForwardTarget(blocks, blockIndex, configuredDefaultNextBlockId) 449 + ) { 377 450 nextIds.add(configuredDefaultNextBlockId); 378 451 } 379 452 } else if (canUseDefaultPath(block)) { ··· 402 475 return false; 403 476 } 404 477 405 - if (rule.operator === "contains" && (block.type === "SHORT_TEXT" || block.type === "LONG_TEXT" || block.type === "LINK")) { 478 + if ( 479 + rule.operator === "contains" && 480 + (block.type === "SHORT_TEXT" || 481 + block.type === "LONG_TEXT" || 482 + block.type === "LINK") 483 + ) { 406 484 return true; 407 485 } 408 486 ··· 410 488 return true; 411 489 } 412 490 413 - if ((rule.operator === "gt" || rule.operator === "gte" || rule.operator === "lt" || rule.operator === "lte") && (block.type === "NUMBER" || block.type === "DATE")) { 491 + if ( 492 + (rule.operator === "gt" || 493 + rule.operator === "gte" || 494 + rule.operator === "lt" || 495 + rule.operator === "lte") && 496 + (block.type === "NUMBER" || block.type === "DATE") 497 + ) { 414 498 return true; 415 499 } 416 500 417 501 return rule.operator === "equals" || rule.operator === "not_equals"; 418 502 } 419 503 420 - function createIssue(issue: Omit<BranchValidationIssue, "severity"> & { severity?: BranchValidationIssueSeverity }): BranchValidationIssue { 504 + function createIssue( 505 + issue: Omit<BranchValidationIssue, "severity"> & { 506 + severity?: BranchValidationIssueSeverity; 507 + }, 508 + ): BranchValidationIssue { 421 509 return { 422 510 severity: issue.severity ?? "blocker", 423 511 ...issue, 424 512 }; 425 513 } 426 514 427 - function validateDefaultTarget(blocks: SerializedBlock[], block: SerializedBlock) { 515 + function validateDefaultTarget( 516 + blocks: SerializedBlock[], 517 + block: SerializedBlock, 518 + ) { 428 519 const issues: BranchValidationIssue[] = []; 429 520 const defaultNextBlockId = getDefaultNextBlockId(block.config); 430 521 ··· 432 523 return issues; 433 524 } 434 525 435 - const sourceIndex = blocks.findIndex((candidate) => candidate.id === block.id); 436 - const targetIndex = blocks.findIndex((candidate) => candidate.id === defaultNextBlockId); 526 + const sourceIndex = blocks.findIndex( 527 + (candidate) => candidate.id === block.id, 528 + ); 529 + const targetIndex = blocks.findIndex( 530 + (candidate) => candidate.id === defaultNextBlockId, 531 + ); 437 532 438 533 if (targetIndex < 0) { 439 534 issues.push( ··· 469 564 const warnings: BranchValidationIssue[] = []; 470 565 471 566 rules.forEach((rule, ruleIndex) => { 472 - if ((block.type === "SHORT_TEXT" || block.type === "LONG_TEXT" || block.type === "LINK") && rule.operator === "equals") { 567 + if ( 568 + (block.type === "SHORT_TEXT" || 569 + block.type === "LONG_TEXT" || 570 + block.type === "LINK") && 571 + rule.operator === "equals" 572 + ) { 473 573 warnings.push( 474 574 createIssue({ 475 575 severity: "warning", ··· 484 584 } 485 585 }); 486 586 487 - const firstIsNotEmptyIndex = rules.findIndex((rule) => rule.operator === "is_not_empty"); 587 + const firstIsNotEmptyIndex = rules.findIndex( 588 + (rule) => rule.operator === "is_not_empty", 589 + ); 488 590 489 591 if (firstIsNotEmptyIndex >= 0) { 490 592 rules.slice(firstIsNotEmptyIndex + 1).forEach((rule, offset) => { ··· 504 606 }); 505 607 } 506 608 507 - if ((block.type === "SINGLE_CHOICE" || block.type === "AGREEMENT") && getDefaultNextBlockId(block.config)) { 609 + if ( 610 + (block.type === "SINGLE_CHOICE" || block.type === "AGREEMENT") && 611 + getDefaultNextBlockId(block.config) 612 + ) { 508 613 const explicitCoverage = new Set( 509 614 rules 510 615 .filter((rule) => rule.operator === "equals") ··· 517 622 ? (block.config as ChoiceBlockConfig).options 518 623 : [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED]; 519 624 520 - if (explicitCoverage.size > 0 && explicitCoverage.size < expectedValues.length) { 625 + if ( 626 + explicitCoverage.size > 0 && 627 + explicitCoverage.size < expectedValues.length 628 + ) { 521 629 warnings.push( 522 630 createIssue({ 523 631 severity: "warning", ··· 534 642 535 643 function validateBlockRules(blocks: SerializedBlock[], block: SerializedBlock) { 536 644 const issues: BranchValidationIssue[] = []; 537 - const sourceIndex = blocks.findIndex((candidate) => candidate.id === block.id); 645 + const sourceIndex = blocks.findIndex( 646 + (candidate) => candidate.id === block.id, 647 + ); 538 648 const seenRuleIdentities = new Set<string>(); 539 649 const rules = getBranchRules(block.config); 540 650 ··· 584 694 585 695 seenRuleIdentities.add(ruleIdentity); 586 696 587 - const targetIndex = blocks.findIndex((candidate) => candidate.id === rule.targetBlockId); 697 + const targetIndex = blocks.findIndex( 698 + (candidate) => candidate.id === rule.targetBlockId, 699 + ); 588 700 589 701 if (targetIndex < 0) { 590 702 issues.push( ··· 653 765 return reachable; 654 766 } 655 767 656 - export function analyzeBranchingGraph(blocks: SerializedBlock[]): BranchValidationAnalysis { 768 + export function analyzeBranchingGraph( 769 + blocks: SerializedBlock[], 770 + ): BranchValidationAnalysis { 657 771 const blockers: BranchValidationIssue[] = []; 658 772 const warnings: BranchValidationIssue[] = []; 659 773 ··· 685 799 return analyzeBranchingGraph(blocks).blockers; 686 800 } 687 801 688 - export function resolveVisitedBlockIds(blocks: SerializedBlock[], answers: Record<string, string | string[]>) { 802 + export function resolveVisitedBlockIds( 803 + blocks: SerializedBlock[], 804 + answers: Record<string, string | string[]>, 805 + ) { 689 806 if (!blocks.length) { 690 807 return []; 691 808 } ··· 703 820 return visitedBlockIds; 704 821 } 705 822 706 - export function getBranchTargetBlocks(blocks: SerializedBlock[], sourceBlockId: string) { 823 + export function getBranchTargetBlocks( 824 + blocks: SerializedBlock[], 825 + sourceBlockId: string, 826 + ) { 707 827 const sourceBlock = blocks.find((block) => block.id === sourceBlockId); 708 828 709 829 if (!sourceBlock) { ··· 733 853 734 854 if (block.type === "AGREEMENT") { 735 855 const label = (block.config as AgreementBlockConfig).label.trim(); 736 - return label ? AGREEMENT_ANSWER_VALUES.AGREED : AGREEMENT_ANSWER_VALUES.AGREED; 856 + return label 857 + ? AGREEMENT_ANSWER_VALUES.AGREED 858 + : AGREEMENT_ANSWER_VALUES.AGREED; 737 859 } 738 860 739 861 return "Exact answer value";
+17 -5
lib/form-defaults.ts
··· 6 6 "Your response was submitted anonymously. The creator can review your answers, but they are not linked to a login or account.", 7 7 } as const; 8 8 9 - export const completionDefaultsByLocale: Record<AppLocale, { title: string; message: string }> = { 9 + export const completionDefaultsByLocale: Record< 10 + AppLocale, 11 + { title: string; message: string } 12 + > = { 10 13 en: { 11 14 title: "Thanks for taking the time.", 12 15 message: ··· 19 22 }, 20 23 }; 21 24 22 - export function getLocalizedCompletionDefaults(locale: AppLocale = DEFAULT_LOCALE) { 23 - return completionDefaultsByLocale[locale] ?? completionDefaultsByLocale[DEFAULT_LOCALE]; 25 + export function getLocalizedCompletionDefaults( 26 + locale: AppLocale = DEFAULT_LOCALE, 27 + ) { 28 + return ( 29 + completionDefaultsByLocale[locale] ?? 30 + completionDefaultsByLocale[DEFAULT_LOCALE] 31 + ); 24 32 } 25 33 26 - export function isLegacyDefaultCompletionTitle(value: string | null | undefined) { 34 + export function isLegacyDefaultCompletionTitle( 35 + value: string | null | undefined, 36 + ) { 27 37 return (value ?? "").trim() === legacyCompletionDefaults.title; 28 38 } 29 39 30 - export function isLegacyDefaultCompletionMessage(value: string | null | undefined) { 40 + export function isLegacyDefaultCompletionMessage( 41 + value: string | null | undefined, 42 + ) { 31 43 return (value ?? "").trim() === legacyCompletionDefaults.message; 32 44 }
+210 -57
lib/forms.ts
··· 26 26 type SerializedBlock, 27 27 type TextAnswerBlockConfig, 28 28 } from "@/lib/blocks"; 29 - import { getBranchValidationIssueI18n, validateBranchingGraph, resolveNextBlockId } from "@/lib/branching"; 29 + import { 30 + getBranchValidationIssueI18n, 31 + validateBranchingGraph, 32 + resolveNextBlockId, 33 + } from "@/lib/branching"; 30 34 import { db } from "@/lib/db"; 31 35 import { AppError } from "@/lib/errors"; 32 36 import { getLocalizedCompletionDefaults } from "@/lib/form-defaults"; ··· 50 54 import { slugify } from "@/lib/utils"; 51 55 import { DEFAULT_LOCALE, translate, type AppLocale } from "@/lib/i18n"; 52 56 import { getMessages } from "@/lib/i18n-server"; 53 - import { assertOrganizationMember, workspaceAccessWhere, workspaceFilterWhere } from "@/lib/workspaces"; 57 + import { 58 + assertOrganizationMember, 59 + workspaceAccessWhere, 60 + workspaceFilterWhere, 61 + } from "@/lib/workspaces"; 54 62 55 63 const FORM_BLOCK_TYPES = { 56 64 TEXT: "TEXT", ··· 125 133 ResponseListItem, 126 134 } from "@/lib/form-types"; 127 135 128 - function toWorkspaceReference(form: { organizationId: string | null; organization: { id: string; name: string } | null }): WorkspaceReference { 136 + function toWorkspaceReference(form: { 137 + organizationId: string | null; 138 + organization: { id: string; name: string } | null; 139 + }): WorkspaceReference { 129 140 if (form.organizationId && form.organization) { 130 141 return { 131 142 kind: "organization", ··· 172 183 }; 173 184 } 174 185 175 - const defaultFormCopyByLocale: Record<AppLocale, { 176 - untitledForm: string; 177 - completionTitle: string; 178 - completionMessage: string; 179 - initialBlocks: Prisma.FormBlockCreateWithoutFormInput[]; 180 - newBlockTitles: Record<PrismaFormBlockType, string>; 181 - }> = { 186 + const defaultFormCopyByLocale: Record< 187 + AppLocale, 188 + { 189 + untitledForm: string; 190 + completionTitle: string; 191 + completionMessage: string; 192 + initialBlocks: Prisma.FormBlockCreateWithoutFormInput[]; 193 + newBlockTitles: Record<PrismaFormBlockType, string>; 194 + } 195 + > = { 182 196 en: { 183 197 untitledForm: "Untitled form", 184 198 completionTitle: getLocalizedCompletionDefaults("en").title, ··· 250 264 }; 251 265 252 266 function getDefaultFormCopy(locale: AppLocale = DEFAULT_LOCALE) { 253 - return defaultFormCopyByLocale[locale] ?? defaultFormCopyByLocale[DEFAULT_LOCALE]; 267 + return ( 268 + defaultFormCopyByLocale[locale] ?? defaultFormCopyByLocale[DEFAULT_LOCALE] 269 + ); 254 270 } 255 271 256 - function initialBlocks(locale: AppLocale = DEFAULT_LOCALE): Prisma.FormBlockCreateWithoutFormInput[] { 272 + function initialBlocks( 273 + locale: AppLocale = DEFAULT_LOCALE, 274 + ): Prisma.FormBlockCreateWithoutFormInput[] { 257 275 return getDefaultFormCopy(locale).initialBlocks; 258 276 } 259 277 ··· 313 331 .slice(0, 10); 314 332 } 315 333 316 - function normalizeBlockConfig(type: PrismaFormBlockType, value: unknown): BlockConfig { 334 + function normalizeBlockConfig( 335 + type: PrismaFormBlockType, 336 + value: unknown, 337 + ): BlockConfig { 317 338 if (isChoiceBlock(type)) { 318 339 const rawOptions = 319 - typeof value === "object" && value && "options" in value && Array.isArray(value.options) 320 - ? value.options.filter((option): option is string => typeof option === "string") 340 + typeof value === "object" && 341 + value && 342 + "options" in value && 343 + Array.isArray(value.options) 344 + ? value.options.filter( 345 + (option): option is string => typeof option === "string", 346 + ) 321 347 : []; 322 348 const options = sanitizeOptions(rawOptions); 323 349 const branchRules = 324 - typeof value === "object" && value && "branchRules" in value && Array.isArray(value.branchRules) 350 + typeof value === "object" && 351 + value && 352 + "branchRules" in value && 353 + Array.isArray(value.branchRules) 325 354 ? value.branchRules 326 355 : undefined; 327 356 const defaultNextBlockId = 328 - typeof value === "object" && value && "defaultNextBlockId" in value && typeof value.defaultNextBlockId === "string" 357 + typeof value === "object" && 358 + value && 359 + "defaultNextBlockId" in value && 360 + typeof value.defaultNextBlockId === "string" 329 361 ? value.defaultNextBlockId 330 - : value && typeof value === "object" && "defaultNextBlockId" in value && value.defaultNextBlockId === null 362 + : value && 363 + typeof value === "object" && 364 + "defaultNextBlockId" in value && 365 + value.defaultNextBlockId === null 331 366 ? null 332 367 : undefined; 333 368 ··· 345 380 return block.title || "this question"; 346 381 } 347 382 348 - function normalizeAnswer(block: SerializedBlock, rawValue: unknown): string | string[] | undefined { 383 + function normalizeAnswer( 384 + block: SerializedBlock, 385 + rawValue: unknown, 386 + ): string | string[] | undefined { 349 387 const config = block.config; 350 388 const questionLabel = getQuestionLabel(block); 351 389 ··· 366 404 } 367 405 368 406 if (validationRegex && !new RegExp(validationRegex).test(value)) { 369 - throw new AppError(`Please use a valid format for “${questionLabel}”.`, 422); 407 + throw new AppError( 408 + `Please use a valid format for “${questionLabel}”.`, 409 + 422, 410 + ); 370 411 } 371 412 372 413 return value; ··· 378 419 379 420 if (!value) { 380 421 if (block.required) { 381 - throw new AppError(`Please choose an option for “${questionLabel}”.`, 422); 422 + throw new AppError( 423 + `Please choose an option for “${questionLabel}”.`, 424 + 422, 425 + ); 382 426 } 383 427 384 428 return undefined; 385 429 } 386 430 387 431 if (!options.includes(value)) { 388 - throw new AppError(`Invalid option submitted for “${questionLabel}”.`, 422); 432 + throw new AppError( 433 + `Invalid option submitted for “${questionLabel}”.`, 434 + 422, 435 + ); 389 436 } 390 437 391 438 return value; ··· 396 443 ? rawValue.filter((value): value is string => typeof value === "string") 397 444 : []; 398 445 const options = (config as ChoiceBlockConfig).options; 399 - const uniqueValues = [...new Set(values.map((value) => value.trim()).filter(Boolean))]; 446 + const uniqueValues = [ 447 + ...new Set(values.map((value) => value.trim()).filter(Boolean)), 448 + ]; 400 449 401 450 if (!uniqueValues.length) { 402 451 if (block.required) { 403 - throw new AppError(`Please choose at least one option for “${questionLabel}”.`, 422); 452 + throw new AppError( 453 + `Please choose at least one option for “${questionLabel}”.`, 454 + 422, 455 + ); 404 456 } 405 457 406 458 return undefined; 407 459 } 408 460 409 461 if (!uniqueValues.every((value) => options.includes(value))) { 410 - throw new AppError(`Invalid option submitted for “${questionLabel}”.`, 422); 462 + throw new AppError( 463 + `Invalid option submitted for “${questionLabel}”.`, 464 + 422, 465 + ); 411 466 } 412 467 413 468 return uniqueValues; ··· 419 474 420 475 if (!value) { 421 476 if (block.required) { 422 - throw new AppError(`Please enter a number for “${questionLabel}”.`, 422); 477 + throw new AppError( 478 + `Please enter a number for “${questionLabel}”.`, 479 + 422, 480 + ); 423 481 } 424 482 425 483 return undefined; ··· 428 486 const numericValue = parseNumericAnswer(value); 429 487 430 488 if (numericValue === null) { 431 - throw new AppError(`Please enter a valid number for “${questionLabel}”.`, 422); 489 + throw new AppError( 490 + `Please enter a valid number for “${questionLabel}”.`, 491 + 422, 492 + ); 432 493 } 433 494 434 495 if (!numberConfig.allowFloat && !Number.isInteger(numericValue)) { 435 - throw new AppError(`Please enter a whole number for “${questionLabel}”.`, 422); 496 + throw new AppError( 497 + `Please enter a whole number for “${questionLabel}”.`, 498 + 422, 499 + ); 436 500 } 437 501 438 502 if (numberConfig.min !== null && numericValue < numberConfig.min) { 439 - throw new AppError(`Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`, 422); 503 + throw new AppError( 504 + `Please enter a number greater than or equal to ${numberConfig.min} for “${questionLabel}”.`, 505 + 422, 506 + ); 440 507 } 441 508 442 509 if (numberConfig.max !== null && numericValue > numberConfig.max) { 443 - throw new AppError(`Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`, 422); 510 + throw new AppError( 511 + `Please enter a number less than or equal to ${numberConfig.max} for “${questionLabel}”.`, 512 + 422, 513 + ); 444 514 } 445 515 446 516 return String(numericValue); ··· 458 528 } 459 529 460 530 if (!isValidLinkAnswer(value)) { 461 - throw new AppError(`Please enter a valid link for “${questionLabel}”.`, 422); 531 + throw new AppError( 532 + `Please enter a valid link for “${questionLabel}”.`, 533 + 422, 534 + ); 462 535 } 463 536 464 537 return new URL(value).toString(); ··· 476 549 } 477 550 478 551 if (!isValidDateAnswer(value)) { 479 - throw new AppError(`Please enter a valid date for “${questionLabel}”.`, 422); 552 + throw new AppError( 553 + `Please enter a valid date for “${questionLabel}”.`, 554 + 422, 555 + ); 480 556 } 481 557 482 558 return value; ··· 487 563 488 564 if (!value) { 489 565 if (block.required) { 490 - throw new AppError(`Agreement is required for “${questionLabel}”.`, 422); 566 + throw new AppError( 567 + `Agreement is required for “${questionLabel}”.`, 568 + 422, 569 + ); 491 570 } 492 571 493 572 return undefined; 494 573 } 495 574 496 575 if (!isAgreementAnswerValue(value)) { 497 - throw new AppError(`Invalid agreement value submitted for “${questionLabel}”.`, 422); 576 + throw new AppError( 577 + `Invalid agreement value submitted for “${questionLabel}”.`, 578 + 422, 579 + ); 498 580 } 499 581 500 582 if (block.required && value !== AGREEMENT_ANSWER_VALUES.AGREED) { ··· 508 590 } 509 591 510 592 function parseSnapshotBlocks(response: ResponseRecord): SerializedBlock[] { 511 - const parsed = z.array(snapshotBlockSchema).safeParse(response.formSnapshotJson); 593 + const parsed = z 594 + .array(snapshotBlockSchema) 595 + .safeParse(response.formSnapshotJson); 512 596 513 597 if (!parsed.success) { 514 598 return response.form.blocks.map(serializeBlock); ··· 530 614 })); 531 615 } 532 616 533 - export async function listFormsForWorkspace(userId: string, workspace: ActiveWorkspace): Promise<FormListItem[]> { 617 + export async function listFormsForWorkspace( 618 + userId: string, 619 + workspace: ActiveWorkspace, 620 + ): Promise<FormListItem[]> { 534 621 if (workspace.kind === "organization") { 535 622 await assertOrganizationMember(userId, workspace.organizationId); 536 623 } ··· 567 654 })); 568 655 } 569 656 570 - export async function createDraftForm(userId: string, workspace: ActiveWorkspace, locale: AppLocale = DEFAULT_LOCALE) { 657 + export async function createDraftForm( 658 + userId: string, 659 + workspace: ActiveWorkspace, 660 + locale: AppLocale = DEFAULT_LOCALE, 661 + ) { 571 662 if (workspace.kind === "organization") { 572 663 await assertOrganizationMember(userId, workspace.organizationId); 573 664 } 574 665 575 - const slug = await createUniqueSlug(`untitled-form-${Math.random().toString(36).slice(2, 6)}`); 666 + const slug = await createUniqueSlug( 667 + `untitled-form-${Math.random().toString(36).slice(2, 6)}`, 668 + ); 576 669 577 670 const copy = getDefaultFormCopy(locale); 578 671 579 672 const form = await db.form.create({ 580 673 data: { 581 674 userId, 582 - organizationId: workspace.kind === "organization" ? workspace.organizationId : null, 675 + organizationId: 676 + workspace.kind === "organization" ? workspace.organizationId : null, 583 677 title: copy.untitledForm, 584 678 description: "", 585 679 completionTitle: copy.completionTitle, ··· 600 694 return toBuilderForm(form); 601 695 } 602 696 603 - export async function updateOwnedFormMetadata(userId: string, formId: string, payload: unknown) { 697 + export async function updateOwnedFormMetadata( 698 + userId: string, 699 + formId: string, 700 + payload: unknown, 701 + ) { 604 702 const parsed = formMetadataSchema.parse(payload); 605 703 await assertAccessibleForm(userId, formId); 606 704 await ensureUniqueSlug(parsed.slug, formId); ··· 622 720 }); 623 721 } 624 722 625 - export async function setOwnedFormPublished(userId: string, formId: string, payload: unknown, locale: AppLocale = DEFAULT_LOCALE) { 723 + export async function setOwnedFormPublished( 724 + userId: string, 725 + formId: string, 726 + payload: unknown, 727 + locale: AppLocale = DEFAULT_LOCALE, 728 + ) { 626 729 const parsed = publishFormSchema.parse(payload); 627 730 const form = await assertAccessibleForm(userId, formId); 628 731 ··· 631 734 } 632 735 633 736 if (parsed.published) { 634 - const branchValidationIssues = validateBranchingGraph(form.blocks.map(serializeBlock)); 737 + const branchValidationIssues = validateBranchingGraph( 738 + form.blocks.map(serializeBlock), 739 + ); 635 740 636 741 if (branchValidationIssues.length > 0) { 637 742 const messages = await getMessages(locale); 638 - const issueI18n = getBranchValidationIssueI18n(branchValidationIssues[0], form.blocks.map(serializeBlock)); 639 - throw new AppError(translate(messages, issueI18n.key, issueI18n.values), 422); 743 + const issueI18n = getBranchValidationIssueI18n( 744 + branchValidationIssues[0], 745 + form.blocks.map(serializeBlock), 746 + ); 747 + throw new AppError( 748 + translate(messages, issueI18n.key, issueI18n.values), 749 + 422, 750 + ); 640 751 } 641 752 } 642 753 ··· 651 762 return toBuilderForm(updated); 652 763 } 653 764 654 - export async function addOwnedBlock(userId: string, formId: string, payload: unknown, locale: AppLocale = DEFAULT_LOCALE) { 765 + export async function addOwnedBlock( 766 + userId: string, 767 + formId: string, 768 + payload: unknown, 769 + locale: AppLocale = DEFAULT_LOCALE, 770 + ) { 655 771 const parsed = createBlockSchema.parse(payload); 656 772 const form = await assertAccessibleForm(userId, formId); 657 773 const position = form.blocks.length; ··· 673 789 return serializeBlock(created); 674 790 } 675 791 676 - export async function updateOwnedBlock(userId: string, formId: string, blockId: string, payload: unknown) { 792 + export async function updateOwnedBlock( 793 + userId: string, 794 + formId: string, 795 + blockId: string, 796 + payload: unknown, 797 + ) { 677 798 const parsed = blockUpdateSchema.parse(payload); 678 799 await assertAccessibleForm(userId, formId); 679 800 ··· 702 823 return serializeBlock(updated); 703 824 } 704 825 705 - export async function deleteOwnedBlock(userId: string, formId: string, blockId: string) { 826 + export async function deleteOwnedBlock( 827 + userId: string, 828 + formId: string, 829 + blockId: string, 830 + ) { 706 831 await assertAccessibleForm(userId, formId); 707 832 708 833 const block = await db.formBlock.findFirst({ ··· 737 862 }); 738 863 } 739 864 740 - export async function reorderOwnedBlocks(userId: string, formId: string, payload: unknown) { 865 + export async function reorderOwnedBlocks( 866 + userId: string, 867 + formId: string, 868 + payload: unknown, 869 + ) { 741 870 const parsed = reorderBlocksSchema.parse(payload); 742 871 const form = await assertAccessibleForm(userId, formId); 743 872 744 873 const currentIds = form.blocks.map((block) => block.id).sort(); 745 874 const nextIds = [...parsed.blockIds].sort(); 746 875 747 - if (currentIds.length !== nextIds.length || currentIds.some((id, index) => id !== nextIds[index])) { 748 - throw new AppError("Block reorder payload is out of sync with the current form.", 409); 876 + if ( 877 + currentIds.length !== nextIds.length || 878 + currentIds.some((id, index) => id !== nextIds[index]) 879 + ) { 880 + throw new AppError( 881 + "Block reorder payload is out of sync with the current form.", 882 + 409, 883 + ); 749 884 } 750 885 751 886 await db.$transaction( ··· 783 918 } 784 919 785 920 export async function createAnonymousResponse(slug: string, payload: unknown) { 786 - const answersInput = z.record(z.string(), z.union([z.string(), z.array(z.string())])).parse(payload); 921 + const answersInput = z 922 + .record(z.string(), z.union([z.string(), z.array(z.string())])) 923 + .parse(payload); 787 924 const form = await db.form.findFirst({ 788 925 where: { 789 926 slug, ··· 819 956 normalizedAnswers[currentBlock.id] = answer; 820 957 } 821 958 822 - const nextBlockId = resolveNextBlockId(blocks, currentBlock.id, normalizedAnswers); 959 + const nextBlockId = resolveNextBlockId( 960 + blocks, 961 + currentBlock.id, 962 + normalizedAnswers, 963 + ); 823 964 currentBlock = nextBlockId ? blocksById.get(nextBlockId) : undefined; 824 965 safetyCounter += 1; 825 966 } ··· 843 984 return response; 844 985 } 845 986 846 - export async function listResponsesForOwnedForm(userId: string, formId: string) { 987 + export async function listResponsesForOwnedForm( 988 + userId: string, 989 + formId: string, 990 + ) { 847 991 const form = await assertAccessibleForm(userId, formId); 848 992 const responses = await db.response.findMany({ 849 993 where: { ··· 863 1007 id: response.id, 864 1008 submittedAt: response.submittedAt.toISOString(), 865 1009 answerCount: 866 - typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) 867 - ? Object.keys(response.answersJson as Record<string, unknown>).length 1010 + typeof response.answersJson === "object" && 1011 + response.answersJson && 1012 + !Array.isArray(response.answersJson) 1013 + ? Object.keys(response.answersJson as Record<string, unknown>) 1014 + .length 868 1015 : 0, 869 1016 submissionNumber: index + 1, 870 1017 })) ··· 872 1019 }; 873 1020 } 874 1021 875 - export async function getOwnedResponseDetail(userId: string, formId: string, responseId: string): Promise<ResponseDetail> { 1022 + export async function getOwnedResponseDetail( 1023 + userId: string, 1024 + formId: string, 1025 + responseId: string, 1026 + ): Promise<ResponseDetail> { 876 1027 await assertAccessibleForm(userId, formId); 877 1028 878 1029 const response = await db.response.findFirst({ ··· 934 1085 formTitle: response.form.title, 935 1086 workspace: toWorkspaceReference(response.form), 936 1087 answers: 937 - typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) 1088 + typeof response.answersJson === "object" && 1089 + response.answersJson && 1090 + !Array.isArray(response.answersJson) 938 1091 ? (response.answersJson as Record<string, string | string[]>) 939 1092 : {}, 940 1093 blocks: visitedBlocks,
+23 -8
lib/i18n-server.ts
··· 7 7 import YAML from "yaml"; 8 8 9 9 import { getServerAuthSession } from "@/lib/auth"; 10 - import { DEFAULT_LOCALE, resolveBrowserLocale, translate, type AppLocale, type TranslationTree } from "@/lib/i18n"; 10 + import { 11 + DEFAULT_LOCALE, 12 + resolveBrowserLocale, 13 + translate, 14 + type AppLocale, 15 + type TranslationTree, 16 + } from "@/lib/i18n"; 11 17 12 18 const localeFileByKey: Record<AppLocale, string> = { 13 19 en: "en.yml", 14 20 ru: "ru.yml", 15 21 }; 16 22 17 - export const getMessages = cache(async (locale: AppLocale): Promise<TranslationTree> => { 18 - const filePath = path.join(process.cwd(), "locales", localeFileByKey[locale]); 19 - const file = await readFile(filePath, "utf8"); 20 - return YAML.parse(file) as TranslationTree; 21 - }); 23 + export const getMessages = cache( 24 + async (locale: AppLocale): Promise<TranslationTree> => { 25 + const filePath = path.join( 26 + process.cwd(), 27 + "locales", 28 + localeFileByKey[locale], 29 + ); 30 + const file = await readFile(filePath, "utf8"); 31 + return YAML.parse(file) as TranslationTree; 32 + }, 33 + ); 22 34 23 35 export async function getRequestLocale(): Promise<AppLocale> { 24 36 const session = await getServerAuthSession(); ··· 28 40 } 29 41 30 42 const headerStore = await headers(); 31 - return resolveBrowserLocale(headerStore.get("accept-language") ?? DEFAULT_LOCALE); 43 + return resolveBrowserLocale( 44 + headerStore.get("accept-language") ?? DEFAULT_LOCALE, 45 + ); 32 46 } 33 47 34 48 export async function getRequestI18n() { ··· 38 52 return { 39 53 locale, 40 54 messages, 41 - t: (key: string, values?: Record<string, string | number>) => translate(messages, key, values), 55 + t: (key: string, values?: Record<string, string | number>) => 56 + translate(messages, key, values), 42 57 }; 43 58 }
+26 -7
lib/i18n.ts
··· 8 8 [key: string]: string | TranslationTree; 9 9 }; 10 10 11 - export function isSupportedLocale(value: string | null | undefined): value is AppLocale { 11 + export function isSupportedLocale( 12 + value: string | null | undefined, 13 + ): value is AppLocale { 12 14 return Boolean(value && supportedLocales.includes(value as AppLocale)); 13 15 } 14 16 ··· 18 20 return isSupportedLocale(base) ? base : DEFAULT_LOCALE; 19 21 } 20 22 21 - export function resolveBrowserLocale(headerValue: string | null | undefined): AppLocale { 23 + export function resolveBrowserLocale( 24 + headerValue: string | null | undefined, 25 + ): AppLocale { 22 26 if (!headerValue) { 23 27 return DEFAULT_LOCALE; 24 28 } ··· 27 31 const locale = rawPart.split(";")[0]?.trim() ?? ""; 28 32 const normalized = normalizeLocale(locale); 29 33 30 - if (isSupportedLocale(locale) || isSupportedLocale(locale.split("-")[0] ?? "")) { 34 + if ( 35 + isSupportedLocale(locale) || 36 + isSupportedLocale(locale.split("-")[0] ?? "") 37 + ) { 31 38 return normalized; 32 39 } 33 40 } ··· 49 56 return typeof current === "string" ? current : null; 50 57 } 51 58 52 - export function interpolateMessage(template: string, values?: Record<string, ReactNode | string | number>) { 59 + export function interpolateMessage( 60 + template: string, 61 + values?: Record<string, ReactNode | string | number>, 62 + ) { 53 63 if (!values) { 54 64 return template; 55 65 } 56 66 57 - return template.replace(/\{(.*?)\}/g, (_, key) => String(values[key.trim()] ?? `{${key}}`)); 67 + return template.replace(/\{(.*?)\}/g, (_, key) => 68 + String(values[key.trim()] ?? `{${key}}`), 69 + ); 58 70 } 59 71 60 - export function translate(messages: TranslationTree, key: string, values?: Record<string, ReactNode | string | number>) { 72 + export function translate( 73 + messages: TranslationTree, 74 + key: string, 75 + values?: Record<string, ReactNode | string | number>, 76 + ) { 61 77 const template = getValue(messages, key) ?? key; 62 78 return interpolateMessage(template, values); 63 79 } 64 80 65 - export function flattenTranslationTree(tree: TranslationTree, prefix = ""): Record<string, string> { 81 + export function flattenTranslationTree( 82 + tree: TranslationTree, 83 + prefix = "", 84 + ): Record<string, string> { 66 85 const entries: Record<string, string> = {}; 67 86 68 87 for (const [key, value] of Object.entries(tree)) {
+46 -11
lib/organizations.ts
··· 7 7 OrganizationMemberSummary, 8 8 OrganizationSettingsSummary, 9 9 } from "@/lib/form-types"; 10 - import { assertOrganizationMember, assertOrganizationOwner } from "@/lib/workspaces"; 10 + import { 11 + assertOrganizationMember, 12 + assertOrganizationOwner, 13 + } from "@/lib/workspaces"; 11 14 12 15 function normalizeOrganizationName(value: unknown) { 13 16 const name = typeof value === "string" ? value.trim() : ""; ··· 17 20 } 18 21 19 22 if (name.length > 80) { 20 - throw new AppError("Organization name must be 80 characters or fewer.", 422); 23 + throw new AppError( 24 + "Organization name must be 80 characters or fewer.", 25 + 422, 26 + ); 21 27 } 22 28 23 29 return name; ··· 59 65 }; 60 66 } 61 67 62 - export async function listOrganizationsForUser(userId: string): Promise<OrganizationSettingsSummary[]> { 68 + export async function listOrganizationsForUser( 69 + userId: string, 70 + ): Promise<OrganizationSettingsSummary[]> { 63 71 const memberships = await db.organizationMembership.findMany({ 64 72 where: { userId }, 65 73 include: { ··· 106 114 })); 107 115 } 108 116 109 - export async function createOrganization(userId: string, payload: { name: unknown }) { 117 + export async function createOrganization( 118 + userId: string, 119 + payload: { name: unknown }, 120 + ) { 110 121 const name = normalizeOrganizationName(payload.name); 111 122 112 123 return db.organization.create({ ··· 123 134 }); 124 135 } 125 136 126 - export async function renameOrganization(userId: string, organizationId: string, payload: { name: unknown }) { 137 + export async function renameOrganization( 138 + userId: string, 139 + organizationId: string, 140 + payload: { name: unknown }, 141 + ) { 127 142 const name = normalizeOrganizationName(payload.name); 128 143 await assertOrganizationOwner(userId, organizationId); 129 144 ··· 133 148 }); 134 149 } 135 150 136 - export async function deleteOrganization(userId: string, organizationId: string) { 151 + export async function deleteOrganization( 152 + userId: string, 153 + organizationId: string, 154 + ) { 137 155 await assertOrganizationOwner(userId, organizationId); 138 156 139 157 await db.organization.delete({ ··· 141 159 }); 142 160 } 143 161 144 - export async function createOrganizationInviteLink(userId: string, organizationId: string) { 162 + export async function createOrganizationInviteLink( 163 + userId: string, 164 + organizationId: string, 165 + ) { 145 166 await assertOrganizationOwner(userId, organizationId); 146 167 147 168 return db.organizationInviteLink.create({ ··· 153 174 }); 154 175 } 155 176 156 - export async function revokeOrganizationInviteLink(userId: string, organizationId: string, inviteLinkId: string) { 177 + export async function revokeOrganizationInviteLink( 178 + userId: string, 179 + organizationId: string, 180 + inviteLinkId: string, 181 + ) { 157 182 await assertOrganizationOwner(userId, organizationId); 158 183 159 184 const inviteLink = await db.organizationInviteLink.findFirst({ ··· 175 200 }); 176 201 } 177 202 178 - export async function removeOrganizationMember(userId: string, organizationId: string, memberUserId: string) { 203 + export async function removeOrganizationMember( 204 + userId: string, 205 + organizationId: string, 206 + memberUserId: string, 207 + ) { 179 208 const ownerMembership = await assertOrganizationOwner(userId, organizationId); 180 209 181 210 if (ownerMembership.userId === memberUserId) { ··· 205 234 }); 206 235 } 207 236 208 - export async function getOrganizationInviteState(userId: string, token: string) { 237 + export async function getOrganizationInviteState( 238 + userId: string, 239 + token: string, 240 + ) { 209 241 const inviteLink = await db.organizationInviteLink.findUnique({ 210 242 where: { token }, 211 243 include: { ··· 266 298 }; 267 299 } 268 300 269 - export async function getOrganizationDetailsForMember(userId: string, organizationId: string) { 301 + export async function getOrganizationDetailsForMember( 302 + userId: string, 303 + organizationId: string, 304 + ) { 270 305 await assertOrganizationMember(userId, organizationId); 271 306 272 307 return db.organization.findUnique({
+2 -1
lib/project.ts
··· 2 2 3 3 export const PROJECT_URLS = { 4 4 source: "https://codeberg.org/chernigin/lively-forms", 5 - license: "https://codeberg.org/chernigin/lively-forms/src/branch/main/LICENSE", 5 + license: 6 + "https://codeberg.org/chernigin/lively-forms/src/branch/main/LICENSE", 6 7 issues: "https://codeberg.org/chernigin/lively-forms/issues", 7 8 } as const;
+108 -42
lib/response-exports.ts
··· 1 1 import { utils, write } from "xlsx"; 2 2 3 - import { AGREEMENT_ANSWER_VALUES, isQuestionBlock, serializeBlock, type SerializedBlock } from "@/lib/blocks"; 3 + import { 4 + AGREEMENT_ANSWER_VALUES, 5 + isQuestionBlock, 6 + serializeBlock, 7 + type SerializedBlock, 8 + } from "@/lib/blocks"; 4 9 import { db } from "@/lib/db"; 5 10 import { AppError } from "@/lib/errors"; 6 11 import { DEFAULT_LOCALE, type AppLocale } from "@/lib/i18n"; ··· 9 14 10 15 export type ResponseExportFormat = "csv" | "xlsx"; 11 16 12 - type OwnedFormExportRecord = Awaited<ReturnType<typeof loadOwnedFormResponsesForExport>>; 17 + type OwnedFormExportRecord = Awaited< 18 + ReturnType<typeof loadOwnedFormResponsesForExport> 19 + >; 13 20 14 21 type ExportAnswerValue = string | string[] | undefined; 15 22 ··· 28 35 rows: ResponseExportRow[]; 29 36 }; 30 37 31 - const exportCopyByLocale: Record<AppLocale, { 32 - metadataColumns: ResponseExportColumn[]; 33 - questionFallback: string; 34 - sheetName: string; 35 - agreed: string; 36 - notAgreed: string; 37 - }> = { 38 + const exportCopyByLocale: Record< 39 + AppLocale, 40 + { 41 + metadataColumns: ResponseExportColumn[]; 42 + questionFallback: string; 43 + sheetName: string; 44 + agreed: string; 45 + notAgreed: string; 46 + } 47 + > = { 38 48 en: { 39 49 metadataColumns: [ 40 50 { key: "submissionNumber", label: "Submission #" }, ··· 63 73 return exportCopyByLocale[locale] ?? exportCopyByLocale[DEFAULT_LOCALE]; 64 74 } 65 75 66 - export async function loadOwnedFormResponsesForExport(userId: string, formId: string) { 76 + export async function loadOwnedFormResponsesForExport( 77 + userId: string, 78 + formId: string, 79 + ) { 67 80 const form = await db.form.findFirst({ 68 81 where: { 69 82 id: formId, ··· 94 107 return form; 95 108 } 96 109 97 - function createQuestionColumnLabel(block: SerializedBlock, questionIndex: number, seenLabels: Map<string, number>, locale: AppLocale) { 110 + function createQuestionColumnLabel( 111 + block: SerializedBlock, 112 + questionIndex: number, 113 + seenLabels: Map<string, number>, 114 + locale: AppLocale, 115 + ) { 98 116 const copy = getExportCopy(locale); 99 - const baseLabel = block.title.trim() || copy.questionFallback.replace("{number}", String(questionIndex)); 117 + const baseLabel = 118 + block.title.trim() || 119 + copy.questionFallback.replace("{number}", String(questionIndex)); 100 120 const occurrence = (seenLabels.get(baseLabel) ?? 0) + 1; 101 121 seenLabels.set(baseLabel, occurrence); 102 122 103 123 return occurrence === 1 ? baseLabel : `${baseLabel} (${occurrence})`; 104 124 } 105 125 106 - export function serializeExportAnswer(value: ExportAnswerValue, block?: SerializedBlock, locale: AppLocale = DEFAULT_LOCALE) { 126 + export function serializeExportAnswer( 127 + value: ExportAnswerValue, 128 + block?: SerializedBlock, 129 + locale: AppLocale = DEFAULT_LOCALE, 130 + ) { 107 131 if (Array.isArray(value)) { 108 132 return value.join(" | "); 109 133 } ··· 114 138 115 139 if (block?.type === "AGREEMENT") { 116 140 const copy = getExportCopy(locale); 117 - return value === AGREEMENT_ANSWER_VALUES.AGREED ? copy.agreed : copy.notAgreed; 141 + return value === AGREEMENT_ANSWER_VALUES.AGREED 142 + ? copy.agreed 143 + : copy.notAgreed; 118 144 } 119 145 120 146 return value; 121 147 } 122 148 123 - export function buildResponseExportDataset(form: OwnedFormExportRecord, locale: AppLocale = DEFAULT_LOCALE): ResponseExportDataset { 124 - const questionBlocks = form.blocks.map(serializeBlock).filter((block) => isQuestionBlock(block.type)); 149 + export function buildResponseExportDataset( 150 + form: OwnedFormExportRecord, 151 + locale: AppLocale = DEFAULT_LOCALE, 152 + ): ResponseExportDataset { 153 + const questionBlocks = form.blocks 154 + .map(serializeBlock) 155 + .filter((block) => isQuestionBlock(block.type)); 125 156 const seenLabels = new Map<string, number>(); 126 157 const copy = getExportCopy(locale); 127 158 ··· 133 164 blockId: block.id, 134 165 })), 135 166 ]; 136 - const questionBlocksById = new Map(questionBlocks.map((block) => [block.id, block])); 167 + const questionBlocksById = new Map( 168 + questionBlocks.map((block) => [block.id, block]), 169 + ); 137 170 138 171 const rows = form.responses.map((response, index) => { 139 172 const answers = 140 - typeof response.answersJson === "object" && response.answersJson && !Array.isArray(response.answersJson) 173 + typeof response.answersJson === "object" && 174 + response.answersJson && 175 + !Array.isArray(response.answersJson) 141 176 ? (response.answersJson as Record<string, ExportAnswerValue>) 142 177 : {}; 143 178 144 - const values = columns.reduce<Record<string, string>>((accumulator, column) => { 145 - if (column.key === "submissionNumber") { 146 - accumulator[column.key] = String(index + 1); 147 - return accumulator; 148 - } 179 + const values = columns.reduce<Record<string, string>>( 180 + (accumulator, column) => { 181 + if (column.key === "submissionNumber") { 182 + accumulator[column.key] = String(index + 1); 183 + return accumulator; 184 + } 149 185 150 - if (column.key === "submittedAt") { 151 - accumulator[column.key] = response.submittedAt.toISOString(); 152 - return accumulator; 153 - } 186 + if (column.key === "submittedAt") { 187 + accumulator[column.key] = response.submittedAt.toISOString(); 188 + return accumulator; 189 + } 154 190 155 - if (column.key === "responseId") { 156 - accumulator[column.key] = response.id; 157 - return accumulator; 158 - } 191 + if (column.key === "responseId") { 192 + accumulator[column.key] = response.id; 193 + return accumulator; 194 + } 159 195 160 - accumulator[column.key] = serializeExportAnswer(answers[column.key], questionBlocksById.get(column.key), locale); 161 - return accumulator; 162 - }, {}); 196 + accumulator[column.key] = serializeExportAnswer( 197 + answers[column.key], 198 + questionBlocksById.get(column.key), 199 + locale, 200 + ); 201 + return accumulator; 202 + }, 203 + {}, 204 + ); 163 205 164 206 return { values }; 165 207 }); ··· 179 221 } 180 222 181 223 export function createCsvExportBuffer(dataset: ResponseExportDataset) { 182 - const headerRow = dataset.columns.map((column) => escapeCsvCell(column.label)).join(","); 183 - const dataRows = dataset.rows.map((row) => dataset.columns.map((column) => escapeCsvCell(row.values[column.key] ?? "")).join(",")); 224 + const headerRow = dataset.columns 225 + .map((column) => escapeCsvCell(column.label)) 226 + .join(","); 227 + const dataRows = dataset.rows.map((row) => 228 + dataset.columns 229 + .map((column) => escapeCsvCell(row.values[column.key] ?? "")) 230 + .join(","), 231 + ); 184 232 const content = [headerRow, ...dataRows].join("\r\n"); 185 233 186 234 return Buffer.from(`\uFEFF${content}`, "utf8"); 187 235 } 188 236 189 - export function createXlsxExportBuffer(dataset: ResponseExportDataset, locale: AppLocale = DEFAULT_LOCALE) { 237 + export function createXlsxExportBuffer( 238 + dataset: ResponseExportDataset, 239 + locale: AppLocale = DEFAULT_LOCALE, 240 + ) { 190 241 const worksheet = utils.aoa_to_sheet([ 191 242 dataset.columns.map((column) => column.label), 192 - ...dataset.rows.map((row) => dataset.columns.map((column) => row.values[column.key] ?? "")), 243 + ...dataset.rows.map((row) => 244 + dataset.columns.map((column) => row.values[column.key] ?? ""), 245 + ), 193 246 ]); 194 247 const workbook = utils.book_new(); 195 248 ··· 201 254 }); 202 255 } 203 256 204 - export function getResponseExportFilename(slug: string, format: ResponseExportFormat) { 257 + export function getResponseExportFilename( 258 + slug: string, 259 + format: ResponseExportFormat, 260 + ) { 205 261 const safeSlug = slugify(slug) || "form"; 206 262 return `${safeSlug}-responses.${format}`; 207 263 } ··· 212 268 : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; 213 269 } 214 270 215 - export function parseResponseExportFormat(value: string | null): ResponseExportFormat { 271 + export function parseResponseExportFormat( 272 + value: string | null, 273 + ): ResponseExportFormat { 216 274 if (value === "csv" || value === "xlsx") { 217 275 return value; 218 276 } ··· 220 278 throw new AppError("Unsupported export format.", 422); 221 279 } 222 280 223 - export async function createOwnedFormResponseExport(userId: string, formId: string, format: ResponseExportFormat, locale: AppLocale = DEFAULT_LOCALE) { 281 + export async function createOwnedFormResponseExport( 282 + userId: string, 283 + formId: string, 284 + format: ResponseExportFormat, 285 + locale: AppLocale = DEFAULT_LOCALE, 286 + ) { 224 287 const form = await loadOwnedFormResponsesForExport(userId, formId); 225 288 const dataset = buildResponseExportDataset(form, locale); 226 289 const filename = getResponseExportFilename(form.slug, format); 227 290 const contentType = getResponseExportContentType(format); 228 - const body = format === "csv" ? createCsvExportBuffer(dataset) : createXlsxExportBuffer(dataset, locale); 291 + const body = 292 + format === "csv" 293 + ? createCsvExportBuffer(dataset) 294 + : createXlsxExportBuffer(dataset, locale); 229 295 230 296 return { 231 297 filename,
+21 -5
lib/theme.ts
··· 6 6 export type ResolvedTheme = "light" | "dark"; 7 7 8 8 export function isThemePreference(value: unknown): value is ThemePreference { 9 - return typeof value === "string" && THEME_PREFERENCES.includes(value as ThemePreference); 9 + return ( 10 + typeof value === "string" && 11 + THEME_PREFERENCES.includes(value as ThemePreference) 12 + ); 10 13 } 11 14 12 15 export function normalizeThemePreference(value: unknown): ThemePreference { 13 16 return isThemePreference(value) ? value : "system"; 14 17 } 15 18 16 - export function resolveTheme(preference: ThemePreference, systemPrefersDark: boolean): ResolvedTheme { 19 + export function resolveTheme( 20 + preference: ThemePreference, 21 + systemPrefersDark: boolean, 22 + ): ResolvedTheme { 17 23 if (preference === "system") { 18 24 return systemPrefersDark ? "dark" : "light"; 19 25 } ··· 21 27 return preference; 22 28 } 23 29 24 - export function applyResolvedTheme(root: HTMLElement, preference: ThemePreference, systemPrefersDark: boolean) { 30 + export function applyResolvedTheme( 31 + root: HTMLElement, 32 + preference: ThemePreference, 33 + systemPrefersDark: boolean, 34 + ) { 25 35 const resolved = resolveTheme(preference, systemPrefersDark); 26 36 27 37 root.dataset.themePreference = preference; ··· 32 42 } 33 43 34 44 export function applyThemePreference(preference: ThemePreference) { 35 - return applyResolvedTheme(document.documentElement, preference, window.matchMedia("(prefers-color-scheme: dark)").matches); 45 + return applyResolvedTheme( 46 + document.documentElement, 47 + preference, 48 + window.matchMedia("(prefers-color-scheme: dark)").matches, 49 + ); 36 50 } 37 51 38 52 export function readStoredThemePreference(): ThemePreference { 39 53 try { 40 - return normalizeThemePreference(window.localStorage.getItem(THEME_STORAGE_KEY)); 54 + return normalizeThemePreference( 55 + window.localStorage.getItem(THEME_STORAGE_KEY), 56 + ); 41 57 } catch { 42 58 return "system"; 43 59 }
+14 -3
lib/user-identity.ts
··· 11 11 return nextValue || null; 12 12 } 13 13 14 - export function getComposedName(firstName?: string | null, secondName?: string | null) { 15 - const composed = [clean(firstName), clean(secondName)].filter(Boolean).join(" ").trim(); 14 + export function getComposedName( 15 + firstName?: string | null, 16 + secondName?: string | null, 17 + ) { 18 + const composed = [clean(firstName), clean(secondName)] 19 + .filter(Boolean) 20 + .join(" ") 21 + .trim(); 16 22 return composed || null; 17 23 } 18 24 ··· 31 37 } 32 38 33 39 export function getUserDisplayName(user: UserIdentityLike) { 34 - return getComposedName(user.firstName, user.secondName) ?? clean(user.name) ?? clean(user.email)?.split("@")[0] ?? "User"; 40 + return ( 41 + getComposedName(user.firstName, user.secondName) ?? 42 + clean(user.name) ?? 43 + clean(user.email)?.split("@")[0] ?? 44 + "User" 45 + ); 35 46 } 36 47 37 48 export function getUserImage(user: UserIdentityLike) {
+18 -5
lib/users.ts
··· 25 25 }; 26 26 } 27 27 28 - export async function getProfileSettingsUser(userId: string): Promise<ProfileSettingsUserSummary> { 28 + export async function getProfileSettingsUser( 29 + userId: string, 30 + ): Promise<ProfileSettingsUserSummary> { 29 31 const user = await db.user.findUnique({ 30 32 where: { id: userId }, 31 33 select: { ··· 46 48 return toProfileSettingsUserSummary(user); 47 49 } 48 50 49 - export async function updateProfileSettings(userId: string, payload: { firstName: unknown; secondName: unknown; locale: unknown; image: unknown }) { 51 + export async function updateProfileSettings( 52 + userId: string, 53 + payload: { 54 + firstName: unknown; 55 + secondName: unknown; 56 + locale: unknown; 57 + image: unknown; 58 + }, 59 + ) { 50 60 const parsed = profileSettingsSchema.parse(payload); 51 61 const name = getComposedName(parsed.firstName, parsed.secondName); 52 62 ··· 129 139 130 140 const parsedName = splitNameParts(clean(profile.name)); 131 141 const nextFirstName = clean(profile.given_name, 60) ?? parsedName.firstName; 132 - const nextSecondName = clean(profile.family_name, 60) ?? parsedName.secondName; 133 - const nextName = clean(profile.name) ?? getComposedName(nextFirstName, nextSecondName); 142 + const nextSecondName = 143 + clean(profile.family_name, 60) ?? parsedName.secondName; 144 + const nextName = 145 + clean(profile.name) ?? getComposedName(nextFirstName, nextSecondName); 134 146 const nextImage = normalizeImageUrl(profile.picture); 135 - const hasProvidedLocale = typeof profile.locale === "string" && profile.locale.trim().length > 0; 147 + const hasProvidedLocale = 148 + typeof profile.locale === "string" && profile.locale.trim().length > 0; 136 149 const nextLocale = hasProvidedLocale ? normalizeLocale(profile.locale) : null; 137 150 138 151 const data: {
+2 -1
lib/utils.ts
··· 24 24 } 25 25 26 26 export function formatCalendarDate(value: Date | string, locale = "en") { 27 - const date = typeof value === "string" ? new Date(`${value}T00:00:00.000Z`) : value; 27 + const date = 28 + typeof value === "string" ? new Date(`${value}T00:00:00.000Z`) : value; 28 29 29 30 return new Intl.DateTimeFormat(locale, { 30 31 dateStyle: "medium",
+6 -2
lib/validators.ts
··· 16 16 .trim() 17 17 .min(2) 18 18 .max(64) 19 - .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."), 19 + .regex( 20 + /^[a-z0-9]+(?:-[a-z0-9]+)*$/, 21 + "Use lowercase letters, numbers, and hyphens only.", 22 + ), 20 23 }) 21 24 .superRefine((value, context) => { 22 25 const hasLabel = Boolean(value.completionLinkLabel); ··· 26 29 context.addIssue({ 27 30 code: z.ZodIssueCode.custom, 28 31 path: [hasLabel ? "completionLinkUrl" : "completionLinkLabel"], 29 - message: "Add both a follow-up link label and URL, or leave both empty.", 32 + message: 33 + "Add both a follow-up link label and URL, or leave both empty.", 30 34 }); 31 35 } 32 36
+33 -9
lib/workspaces.ts
··· 8 8 export const ACTIVE_WORKSPACE_COOKIE = "lively-active-workspace"; 9 9 10 10 export function serializeWorkspaceValue(workspace: ActiveWorkspace) { 11 - return workspace.kind === "personal" ? "personal" : `organization:${workspace.organizationId}`; 11 + return workspace.kind === "personal" 12 + ? "personal" 13 + : `organization:${workspace.organizationId}`; 12 14 } 13 15 14 - export function parseWorkspaceValue(value: string | null | undefined): ActiveWorkspace { 16 + export function parseWorkspaceValue( 17 + value: string | null | undefined, 18 + ): ActiveWorkspace { 15 19 if (!value || value === "personal") { 16 20 return { kind: "personal" }; 17 21 } ··· 42 46 cookieStore.delete(ACTIVE_WORKSPACE_COOKIE); 43 47 } 44 48 45 - export async function listCreatorWorkspaceOptions(userId: string): Promise<CreatorWorkspaceOption[]> { 49 + export async function listCreatorWorkspaceOptions( 50 + userId: string, 51 + ): Promise<CreatorWorkspaceOption[]> { 46 52 const memberships = await db.organizationMembership.findMany({ 47 53 where: { userId }, 48 54 include: { ··· 66 72 kind: "organization" as const, 67 73 organizationId: membership.organizationId, 68 74 label: membership.organization.name, 69 - value: serializeWorkspaceValue({ kind: "organization", organizationId: membership.organizationId }), 75 + value: serializeWorkspaceValue({ 76 + kind: "organization", 77 + organizationId: membership.organizationId, 78 + }), 70 79 isOwner: membership.role === OrganizationMemberRole.OWNER, 71 80 })), 72 81 ]; ··· 75 84 export async function getActiveWorkspaceForUser(userId: string) { 76 85 const options = await listCreatorWorkspaceOptions(userId); 77 86 const cookieStore = await cookies(); 78 - const parsed = parseWorkspaceValue(cookieStore.get(ACTIVE_WORKSPACE_COOKIE)?.value); 87 + const parsed = parseWorkspaceValue( 88 + cookieStore.get(ACTIVE_WORKSPACE_COOKIE)?.value, 89 + ); 79 90 80 91 if (parsed.kind === "organization") { 81 - const match = options.find((option) => option.kind === "organization" && option.organizationId === parsed.organizationId); 92 + const match = options.find( 93 + (option) => 94 + option.kind === "organization" && 95 + option.organizationId === parsed.organizationId, 96 + ); 82 97 83 98 if (match) { 84 99 return { ··· 116 131 }; 117 132 } 118 133 119 - export function workspaceFilterWhere(userId: string, workspace: ActiveWorkspace): Prisma.FormWhereInput { 134 + export function workspaceFilterWhere( 135 + userId: string, 136 + workspace: ActiveWorkspace, 137 + ): Prisma.FormWhereInput { 120 138 if (workspace.kind === "personal") { 121 139 return { 122 140 userId, ··· 136 154 }; 137 155 } 138 156 139 - export async function assertOrganizationMember(userId: string, organizationId: string) { 157 + export async function assertOrganizationMember( 158 + userId: string, 159 + organizationId: string, 160 + ) { 140 161 const membership = await db.organizationMembership.findUnique({ 141 162 where: { 142 163 organizationId_userId: { ··· 156 177 return membership; 157 178 } 158 179 159 - export async function assertOrganizationOwner(userId: string, organizationId: string) { 180 + export async function assertOrganizationOwner( 181 + userId: string, 182 + organizationId: string, 183 + ) { 160 184 const membership = await assertOrganizationMember(userId, organizationId); 161 185 162 186 if (membership.role !== OrganizationMemberRole.OWNER) {
+12 -12
locales/en.yml
··· 167 167 branchOtherwise: Otherwise continue to 168 168 branchDefaultLinear: The next block in saved order 169 169 branchNoValueNeeded: No value needed for this operator. 170 - branchingHelp: "Leave a rule unmatched to use the default path. Example value: \"{value}\"." 170 + branchingHelp: 'Leave a rule unmatched to use the default path. Example value: "{value}".' 171 171 branchingNoTargets: Add a later block before creating branch rules from this question. 172 172 dragBranchRuleAria: "Drag branch rule {number}" 173 173 removeBranchRuleAria: "Remove branch rule {number}" ··· 240 240 DATE: Date 241 241 branching: 242 242 issueMessages: 243 - invalid-target: "A rule on \"{blockLabel}\" points to a missing block." 244 - backward-target: "A rule on \"{blockLabel}\" must point to a later block." 245 - invalid-default-target: "The default next step on \"{blockLabel}\" points to a missing block." 246 - backward-default-target: "The default next step on \"{blockLabel}\" must point to a later block." 247 - invalid-condition: "A rule on \"{blockLabel}\" has an invalid condition value." 248 - duplicate-condition: "A rule on \"{blockLabel}\" duplicates an earlier condition." 249 - unsupported-operator: "A rule on \"{blockLabel}\" uses an operator that does not fit this block type." 250 - unreachable-block: "\"{blockLabel}\" is unreachable from the start of the form." 251 - fragile-text-match: "Exact text matching on \"{blockLabel}\" may be fragile if respondents vary punctuation or casing." 252 - overlapping-rule: "A later rule on \"{blockLabel}\" may never run because an earlier broader rule matches first." 253 - partial-choice-coverage: "Some answers on \"{blockLabel}\" fall through to the default route. Double-check that this is intentional." 243 + invalid-target: 'A rule on "{blockLabel}" points to a missing block.' 244 + backward-target: 'A rule on "{blockLabel}" must point to a later block.' 245 + invalid-default-target: 'The default next step on "{blockLabel}" points to a missing block.' 246 + backward-default-target: 'The default next step on "{blockLabel}" must point to a later block.' 247 + invalid-condition: 'A rule on "{blockLabel}" has an invalid condition value.' 248 + duplicate-condition: 'A rule on "{blockLabel}" duplicates an earlier condition.' 249 + unsupported-operator: 'A rule on "{blockLabel}" uses an operator that does not fit this block type.' 250 + unreachable-block: '"{blockLabel}" is unreachable from the start of the form.' 251 + fragile-text-match: 'Exact text matching on "{blockLabel}" may be fragile if respondents vary punctuation or casing.' 252 + overlapping-rule: 'A later rule on "{blockLabel}" may never run because an earlier broader rule matches first.' 253 + partial-choice-coverage: 'Some answers on "{blockLabel}" fall through to the default route. Double-check that this is intentional.' 254 254 publicRunner: 255 255 responseReceived: Response received 256 256 defaultCompletionTitle: Thanks for taking the time.
+12 -12
locales/ru.yml
··· 167 167 branchOtherwise: Иначе продолжить к 168 168 branchDefaultLinear: Следующему блоку в сохранённом порядке 169 169 branchNoValueNeeded: Для этого оператора значение не требуется. 170 - branchingHelp: "Если ни одно правило не совпало, форма использует маршрут по умолчанию. Пример значения: \"{value}\"." 170 + branchingHelp: 'Если ни одно правило не совпало, форма использует маршрут по умолчанию. Пример значения: "{value}".' 171 171 branchingNoTargets: Добавьте более поздний блок, прежде чем создавать правила ветвления для этого вопроса. 172 172 dragBranchRuleAria: "Перетащить правило ветвления {number}" 173 173 removeBranchRuleAria: "Удалить правило ветвления {number}" ··· 240 240 DATE: Дата 241 241 branching: 242 242 issueMessages: 243 - invalid-target: "Правило в \"{blockLabel}\" ссылается на отсутствующий блок." 244 - backward-target: "Правило в \"{blockLabel}\" должно вести только к более позднему блоку." 245 - invalid-default-target: "Маршрут по умолчанию в \"{blockLabel}\" ссылается на отсутствующий блок." 246 - backward-default-target: "Маршрут по умолчанию в \"{blockLabel}\" должен вести только к более позднему блоку." 247 - invalid-condition: "В \"{blockLabel}\" задано некорректное значение условия." 248 - duplicate-condition: "В \"{blockLabel}\" условие дублирует более раннее правило." 249 - unsupported-operator: "В \"{blockLabel}\" используется оператор, который не подходит этому типу блока." 250 - unreachable-block: "Блок \"{blockLabel}\" недостижим от начала формы." 251 - fragile-text-match: "Точное текстовое совпадение в \"{blockLabel}\" может быть хрупким, если респонденты вводят разную пунктуацию или регистр." 252 - overlapping-rule: "Более позднее правило в \"{blockLabel}\" может никогда не сработать, потому что раньше срабатывает более широкое условие." 253 - partial-choice-coverage: "Часть ответов в \"{blockLabel}\" уходит по маршруту по умолчанию. Проверьте, что это сделано намеренно." 243 + invalid-target: 'Правило в "{blockLabel}" ссылается на отсутствующий блок.' 244 + backward-target: 'Правило в "{blockLabel}" должно вести только к более позднему блоку.' 245 + invalid-default-target: 'Маршрут по умолчанию в "{blockLabel}" ссылается на отсутствующий блок.' 246 + backward-default-target: 'Маршрут по умолчанию в "{blockLabel}" должен вести только к более позднему блоку.' 247 + invalid-condition: 'В "{blockLabel}" задано некорректное значение условия.' 248 + duplicate-condition: 'В "{blockLabel}" условие дублирует более раннее правило.' 249 + unsupported-operator: 'В "{blockLabel}" используется оператор, который не подходит этому типу блока.' 250 + unreachable-block: 'Блок "{blockLabel}" недостижим от начала формы.' 251 + fragile-text-match: 'Точное текстовое совпадение в "{blockLabel}" может быть хрупким, если респонденты вводят разную пунктуацию или регистр.' 252 + overlapping-rule: 'Более позднее правило в "{blockLabel}" может никогда не сработать, потому что раньше срабатывает более широкое условие.' 253 + partial-choice-coverage: 'Часть ответов в "{blockLabel}" уходит по маршруту по умолчанию. Проверьте, что это сделано намеренно.' 254 254 publicRunner: 255 255 responseReceived: Ответ получен 256 256 defaultCompletionTitle: Спасибо, что уделили время.
+6 -1
package.json
··· 17 17 "dev": "next dev", 18 18 "build": "node scripts/validate-translations.mjs && next build", 19 19 "start": "next start", 20 - "lint": "eslint", 20 + "lint": "eslint . --max-warnings=0", 21 + "typecheck": "tsc --noEmit", 22 + "format": "prettier --write .", 23 + "format:check": "prettier --check . --ignore-unknown", 24 + "check": "bun run format:check && bun run lint && bun run typecheck && bun run build", 21 25 "db:up": "podman-compose up -d", 22 26 "db:down": "podman-compose down", 23 27 "db:logs": "podman-compose logs -f postgres", ··· 34 38 "@types/react-dom": "19.2.3", 35 39 "eslint": "9.39.1", 36 40 "eslint-config-next": "16.2.3", 41 + "prettier": "^3.8.2", 37 42 "prisma": "7.7.0", 38 43 "tailwindcss": "4.2.2", 39 44 "typescript": "6.0.2"
+1 -1
tsconfig.json
··· 31 31 "**/*.mts" 32 32 ], 33 33 "exclude": ["node_modules"] 34 - } 34 + }