diff --git a/apps/web/src/app/api/forms/[slug]/upload/route.ts b/apps/web/src/app/api/forms/[slug]/upload/route.ts index 074a9f2..41bbfd1 100644 --- a/apps/web/src/app/api/forms/[slug]/upload/route.ts +++ b/apps/web/src/app/api/forms/[slug]/upload/route.ts @@ -72,9 +72,10 @@ export async function POST( const allowed = field.validation.allowedMimeTypes; const mime = file.type || "application/octet-stream"; + const wantsImage = field.type === "image_upload" || field.type === "signature"; const mimeOk = allowed?.length ? allowed.includes(mime) - : field.type === "image_upload" + : wantsImage ? mime.startsWith("image/") : true; if (!mimeOk) { diff --git a/apps/web/src/app/f/[slug]/page.tsx b/apps/web/src/app/f/[slug]/page.tsx index 30dc2a5..7a521a3 100644 --- a/apps/web/src/app/f/[slug]/page.tsx +++ b/apps/web/src/app/f/[slug]/page.tsx @@ -87,6 +87,7 @@ export default async function PublicFormPage({ fileUploading: t.fileUploading, fileRemove: t.fileRemove, uploadFailed: t.uploadFailed, + signatureClear: t.signatureClear, }} /> diff --git a/apps/web/src/components/form/field-input.tsx b/apps/web/src/components/form/field-input.tsx index 36356b1..818a8a3 100644 --- a/apps/web/src/components/form/field-input.tsx +++ b/apps/web/src/components/form/field-input.tsx @@ -13,6 +13,7 @@ import { import { FileField, type FileFieldLabels } from "./file-field"; import { MatrixField } from "./matrix-field"; import { ScaleButtons, SliderInput, StarRating } from "./rating-fields"; +import { SignatureField } from "./signature-field"; export type FieldValue = | string @@ -149,6 +150,18 @@ export function FieldInput({ /> ); + case "signature": + return ( + + ); + case "single_choice": return ( diff --git a/apps/web/src/components/form/signature-field.tsx b/apps/web/src/components/form/signature-field.tsx new file mode 100644 index 0000000..eb5fbdf --- /dev/null +++ b/apps/web/src/components/form/signature-field.tsx @@ -0,0 +1,132 @@ +"use client"; + +import type { FileAnswer, FormField } from "@msk-forms/shared"; +import { useRef, useState } from "react"; + +import type { FileFieldLabels } from "./file-field"; + +/** + * Signature pad: the applicant draws on a canvas; on each completed stroke the + * drawing is exported to a PNG and uploaded through the form's normal upload + * endpoint (server → MinIO), so the answer is a `FileAnswer` like any file field. + */ +export function SignatureField({ + slug, + field, + value, + onChange, + disabled, + labels, +}: { + slug: string; + field: FormField; + value: FileAnswer | undefined; + onChange: (value: FileAnswer | undefined) => void; + disabled?: boolean; + labels: FileFieldLabels; +}) { + const canvasRef = useRef(null); + const drawing = useRef(false); + const dirty = useRef(false); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + + function pos(e: React.PointerEvent) { + const rect = e.currentTarget.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function start(e: React.PointerEvent) { + if (disabled) return; + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + drawing.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + const { x, y } = pos(e); + ctx.beginPath(); + ctx.moveTo(x, y); + } + + function move(e: React.PointerEvent) { + if (!drawing.current) return; + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + const { x, y } = pos(e); + ctx.lineWidth = 2; + ctx.lineCap = "round"; + ctx.strokeStyle = "#0a0a0a"; + ctx.lineTo(x, y); + ctx.stroke(); + dirty.current = true; + } + + async function end() { + if (!drawing.current) return; + drawing.current = false; + if (dirty.current) await upload(); + } + + function clear() { + const canvas = canvasRef.current; + const ctx = canvas?.getContext("2d"); + if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + dirty.current = false; + setError(null); + onChange(undefined); + } + + async function upload() { + const canvas = canvasRef.current; + if (!canvas) return; + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); + if (!blob) return; + + setError(null); + setUploading(true); + try { + const fd = new FormData(); + fd.set("file", new File([blob], "signature.png", { type: "image/png" })); + fd.set("fieldId", field.id); + const res = await fetch(`/api/forms/${slug}/upload`, { method: "POST", body: fd }); + if (!res.ok) { + const data = (await res.json().catch(() => null)) as { error?: string } | null; + throw new Error(data?.error ?? labels.uploadFailed); + } + const data = (await res.json()) as FileAnswer; + onChange({ key: data.key, name: data.name, size: data.size, mime: data.mime }); + } catch (err) { + setError(err instanceof Error ? err.message : labels.uploadFailed); + onChange(undefined); + } finally { + setUploading(false); + } + } + + return ( +
+ +
+ + {uploading && {labels.uploading}} + {!uploading && value && } + {error && {error}} +
+
+ ); +} diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index f59ed85..c4046ae 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -48,6 +48,7 @@ const en = { submitFailed: "Submission failed.", captchaRequired: "Please complete the captcha.", fileUploading: "Uploading…", fileRemove: "Remove", uploadFailed: "Upload failed.", + signatureClear: "Clear", }, status: { yourSubmission: "Your submission", activity: "Activity", yourAnswers: "Your answers", @@ -139,7 +140,7 @@ const en = { short_text: "Short text", long_text: "Long text", email: "Email", number: "Number", phone: "Phone", url: "URL", single_choice: "Single choice", dropdown: "Dropdown", multi_choice: "Multiple choice", yes_no: "Yes / No", rating_stars: "Star rating", nps: "NPS (0–10)", slider: "Slider", emoji_scale: "Emoji scale", matrix: "Matrix", - date: "Date", file_upload: "File upload", image_upload: "Image upload", + date: "Date", file_upload: "File upload", image_upload: "Image upload", signature: "Signature", consent: "Consent", heading: "Heading", paragraph: "Paragraph", divider: "Divider", }, }, @@ -203,6 +204,7 @@ const de: Dictionary = { submitFailed: "Senden fehlgeschlagen.", captchaRequired: "Bitte das Captcha lösen.", fileUploading: "Wird hochgeladen…", fileRemove: "Entfernen", uploadFailed: "Upload fehlgeschlagen.", + signatureClear: "Löschen", }, status: { yourSubmission: "Deine Einreichung", activity: "Aktivität", yourAnswers: "Deine Antworten", @@ -294,7 +296,7 @@ const de: Dictionary = { short_text: "Kurztext", long_text: "Langtext", email: "E-Mail", number: "Zahl", phone: "Telefon", url: "URL", single_choice: "Einfachauswahl", dropdown: "Dropdown", multi_choice: "Mehrfachauswahl", yes_no: "Ja / Nein", rating_stars: "Sterne-Bewertung", nps: "NPS (0–10)", slider: "Schieberegler", emoji_scale: "Emoji-Skala", matrix: "Matrix", - date: "Datum", file_upload: "Datei-Upload", image_upload: "Bild-Upload", + date: "Datum", file_upload: "Datei-Upload", image_upload: "Bild-Upload", signature: "Unterschrift", consent: "Zustimmung", heading: "Überschrift", paragraph: "Absatz", divider: "Trenner", }, }, diff --git a/apps/web/src/lib/builder-fields.ts b/apps/web/src/lib/builder-fields.ts index f21f78f..06f2292 100644 --- a/apps/web/src/lib/builder-fields.ts +++ b/apps/web/src/lib/builder-fields.ts @@ -20,6 +20,7 @@ export const BUILDER_FIELDS: { type: FieldType; label: string }[] = [ { type: "date", label: "Date" }, { type: "file_upload", label: "File upload" }, { type: "image_upload", label: "Image upload" }, + { type: "signature", label: "Signature" }, { type: "consent", label: "Consent" }, { type: "heading", label: "Heading" }, { type: "paragraph", label: "Paragraph" }, diff --git a/packages/shared/src/form-spec.test.ts b/packages/shared/src/form-spec.test.ts index bb3e3a0..8a7080d 100644 --- a/packages/shared/src/form-spec.test.ts +++ b/packages/shared/src/form-spec.test.ts @@ -197,6 +197,13 @@ describe("buildAnswerSchema", () => { expect(s.safeParse({}).success).toBe(true); expect(s.safeParse({ m: { r1: "y" } }).success).toBe(true); }); + + it("treats a signature as a file-reference answer", () => { + const s = buildAnswerSchema(specWith({ id: "sig", type: "signature", validation: { required: true } })); + const ok = { sig: { key: "uploads/f/abc", name: "signature.png", size: 120, mime: "image/png" } }; + expect(s.safeParse(ok).success).toBe(true); + expect(s.safeParse({ sig: "not-a-file" }).success).toBe(false); + }); }); describe("formatAnswerValue (rating family)", () => { diff --git a/packages/shared/src/form-spec.ts b/packages/shared/src/form-spec.ts index f403667..853e9e3 100644 --- a/packages/shared/src/form-spec.ts +++ b/packages/shared/src/form-spec.ts @@ -136,8 +136,12 @@ export const formSpecSchema = z.object({ }); export type FormSpec = z.infer; -/** Field types whose answer is an uploaded file reference, not a scalar. */ -export const FILE_FIELD_TYPES = ["file_upload", "image_upload"] as const; +/** + * Field types whose answer is an uploaded file reference, not a scalar. + * `signature` is included: the canvas drawing is uploaded as a PNG and stored + * exactly like any other file answer. + */ +export const FILE_FIELD_TYPES = ["file_upload", "image_upload", "signature"] as const; /** * The answer value for a file field: a reference to an object already uploaded