Add visible feedback and error handling to commission requests

This commit is contained in:
2026-01-01 11:53:13 +01:00
parent 84470aa2e2
commit af5e2dd590
3 changed files with 223 additions and 161 deletions

View File

@ -67,22 +67,32 @@ export async function submitCommissionRequest(input: {
); );
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const raw = await res.text().catch(() => "");
let parsed: any = null; const statusLine = `${res.status} ${res.statusText || ""}`.trim();
// Show something useful even if raw is empty
let message = `Admin API error: ${statusLine}`;
if (raw) {
try { try {
parsed = text ? JSON.parse(text) : null; const parsed = JSON.parse(raw);
message =
parsed?.error
? `Admin API error: ${statusLine}${parsed.error}`
: parsed?.message
? `Admin API error: ${statusLine}${parsed.message}`
: `Admin API error: ${statusLine}${raw}`;
} catch { } catch {
// ignore message = `Admin API error: ${statusLine}${raw}`;
} }
const message =
parsed?.error ??
parsed?.message ??
(text ? text.slice(0, 300) : `Request failed (${res.status})`);
throw new Error(message);
} }
// Log full body server-side for debugging (safe; this is server-only)
console.error("[submitCommissionRequest] upstream error", { statusLine, raw });
throw new Error(message);
}
// Expected response: { id: string; createdAt: string } // Expected response: { id: string; createdAt: string }
return (await res.json()) as { id: string; createdAt: string }; return (await res.json()) as { id: string; createdAt: string };
} }

View File

@ -1,7 +1,7 @@
"use client" "use client";
import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest" import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
@ -9,29 +9,37 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea";
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client" import {
import { commissionOrderSchema } from "@/schemas/commissionOrder" CommissionCustomInput,
import { calculatePriceRange } from "@/utils/calculatePrice" CommissionExtra,
import { zodResolver } from "@hookform/resolvers/zod" CommissionOption,
import "dotenv/config" CommissionType,
import Link from "next/link" CommissionTypeCustomInput,
import { useMemo, useState } from "react" CommissionTypeExtra,
import { useForm, useWatch } from "react-hook-form" CommissionTypeOption,
import * as z from "zod/v4" } from "@/generated/prisma/client";
import { FileDropzone } from "./FileDropzone" import { commissionOrderSchema } from "@/schemas/commissionOrder";
import { calculatePriceRange } from "@/utils/calculatePrice";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
import { FileDropzone } from "./FileDropzone";
type CommissionTypeWithRelations = CommissionType & { type CommissionTypeWithRelations = CommissionType & {
options: (CommissionTypeOption & { option: CommissionOption })[] options: (CommissionTypeOption & { option: CommissionOption })[];
extras: (CommissionTypeExtra & { extra: CommissionExtra })[] extras: (CommissionTypeExtra & { extra: CommissionExtra })[];
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[] customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
} };
type Props = { type Props = {
types: CommissionTypeWithRelations[] types: CommissionTypeWithRelations[];
} };
export function CommissionOrderForm({ types }: Props) { export function CommissionOrderForm({ types }: Props) {
const form = useForm<z.infer<typeof commissionOrderSchema>>({ const form = useForm<z.infer<typeof commissionOrderSchema>>({
@ -45,50 +53,71 @@ export function CommissionOrderForm({ types }: Props) {
customerSocials: "", customerSocials: "",
message: "", message: "",
}, },
}) });
const [files, setFiles] = useState<File[]>([]) const [files, setFiles] = useState<File[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const typeId = useWatch({ control: form.control, name: "typeId" }) const typeId = useWatch({ control: form.control, name: "typeId" });
const optionId = useWatch({ control: form.control, name: "optionId" }) const optionId = useWatch({ control: form.control, name: "optionId" });
const extraIds = useWatch({ control: form.control, name: "extraIds" }) const extraIds = useWatch({ control: form.control, name: "extraIds" });
const selectedType = useMemo( const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
() => types.find((t) => t.id === typeId),
[types, typeId]
)
const selectedOption = useMemo( const selectedOption = useMemo(
() => selectedType?.options.find((o) => o.optionId === optionId), () => selectedType?.options.find((o) => o.optionId === optionId),
[selectedType, optionId] [selectedType, optionId]
) );
const selectedExtras = useMemo( const selectedExtras = useMemo(
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [], () => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
[selectedType, extraIds] [selectedType, extraIds]
) );
const [minPrice, maxPrice] = useMemo(() => { const [minPrice, maxPrice] = useMemo(() => {
return calculatePriceRange(selectedOption, selectedExtras) return calculatePriceRange(selectedOption, selectedExtras);
}, [selectedOption, selectedExtras]) }, [selectedOption, selectedExtras]);
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) { async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
const payload = { setIsSubmitting(true);
typeId: values.typeId || null,
optionId: values.optionId || null,
customerName: values.customerName,
customerEmail: values.customerEmail,
customerSocials: values.customerSocials ?? null,
message: values.message,
extraIds: values.extraIds ?? [], // <-- normalize
};
const res = await submitCommissionRequest({ try {
payload, const payload = {
files, typeId: values.typeId || null,
}); optionId: values.optionId || null,
extraIds: values.extraIds ?? [],
customerName: values.customerName,
customerEmail: values.customerEmail,
customerSocials: values.customerSocials ?? null,
message: values.message,
};
console.log("Created request:", res); await submitCommissionRequest({ payload, files });
toast.success("Request submitted", {
description: "Thanks! Ill get back to you as soon as possible.",
});
form.reset({
typeId: "",
optionId: "",
extraIds: [],
customerName: "",
customerEmail: "",
customerSocials: "",
message: "",
});
setFiles([]);
form.clearErrors();
} catch (err) {
const message =
err instanceof Error ? err.message : "Submission failed. Please try again.";
toast.error("Submission failed", { description: message });
} finally {
setIsSubmitting(false);
}
} }
return ( return (
@ -108,6 +137,7 @@ export function CommissionOrderForm({ types }: Props) {
type="button" type="button"
variant={field.value === type.id ? "default" : "outline"} variant={field.value === type.id ? "default" : "outline"}
onClick={() => field.onChange(type.id)} onClick={() => field.onChange(type.id)}
disabled={isSubmitting}
> >
{type.name} {type.name}
</Button> </Button>
@ -136,6 +166,7 @@ export function CommissionOrderForm({ types }: Props) {
checked={field.value === opt.optionId} checked={field.value === opt.optionId}
value={opt.optionId} value={opt.optionId}
onChange={() => field.onChange(opt.optionId)} onChange={() => field.onChange(opt.optionId)}
disabled={isSubmitting}
/> />
{opt.option.name} {opt.option.name}
</label> </label>
@ -155,21 +186,19 @@ export function CommissionOrderForm({ types }: Props) {
<FormLabel>Extras</FormLabel> <FormLabel>Extras</FormLabel>
<FormControl> <FormControl>
<div className="space-y-1"> <div className="space-y-1">
{selectedType?.extras.map((ext) => ( {selectedType.extras.map((ext) => (
<label key={ext.id} className="flex items-center gap-2"> <label key={ext.id} className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={field.value?.includes(ext.extraId) ?? false} checked={field.value?.includes(ext.extraId) ?? false}
value={ext.extraId} value={ext.extraId}
disabled={isSubmitting}
onChange={(e) => { onChange={(e) => {
const checked = e.target.checked const checked = e.target.checked;
const newSet = new Set(field.value ?? []) const newSet = new Set(field.value ?? []);
if (checked) { if (checked) newSet.add(ext.extraId);
newSet.add(ext.extraId) else newSet.delete(ext.extraId);
} else { field.onChange(Array.from(newSet));
newSet.delete(ext.extraId)
}
field.onChange(Array.from(newSet))
}} }}
/> />
{ext.extra.name} {ext.extra.name}
@ -184,8 +213,6 @@ export function CommissionOrderForm({ types }: Props) {
</> </>
)} )}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField <FormField
control={form.control} control={form.control}
@ -194,12 +221,13 @@ export function CommissionOrderForm({ types }: Props) {
<FormItem> <FormItem>
<FormLabel>Your Name</FormLabel> <FormLabel>Your Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Jane Doe" {...field} /> <Input placeholder="Jane Doe" {...field} disabled={isSubmitting} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="customerEmail" name="customerEmail"
@ -207,12 +235,17 @@ export function CommissionOrderForm({ types }: Props) {
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="E-Mail address for invoice and/or contact" {...field} /> <Input
placeholder="E-Mail address for invoice and/or contact"
{...field}
disabled={isSubmitting}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="customerSocials" name="customerSocials"
@ -220,7 +253,11 @@ export function CommissionOrderForm({ types }: Props) {
<FormItem> <FormItem>
<FormLabel>Socials</FormLabel> <FormLabel>Socials</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)" {...field} /> <Input
placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)"
{...field}
disabled={isSubmitting}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@ -239,6 +276,7 @@ export function CommissionOrderForm({ types }: Props) {
placeholder="Describe what youd like drawn..." placeholder="Describe what youd like drawn..."
rows={4} rows={4}
{...field} {...field}
disabled={isSubmitting}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@ -246,65 +284,32 @@ export function CommissionOrderForm({ types }: Props) {
)} )}
/> />
{selectedType && selectedType.customInputs.length > 0 && (
<div className="space-y-4">
{selectedType.customInputs.map((input) => {
const name = `customFields.${input.customInput.name}`
return (
<FormField
key={input.id}
control={form.control}
name={name as `customFields.${string}`}
render={({ field }) => (
<FormItem>
<FormLabel>{input.label}</FormLabel>
<FormControl>
{input.inputType === "textarea" ? (
<Textarea {...field} rows={3} />
) : input.inputType === "number" ? (
<Input type="number" {...field} />
) : input.inputType === "checkbox" ? (
<input
type="checkbox"
checked={field.value ?? false}
onChange={(e) => field.onChange(e.target.checked)}
/>
) : input.inputType === "date" ? (
<Input type="date" {...field} />
) : input.inputType === "select" ? (
// Placeholder select populate with options if needed
<select
{...field}
className="border rounded px-2 py-1 w-full"
>
<option value="">Please select</option>
<option value="example1">Example 1</option>
<option value="example2">Example 2</option>
</select>
) : (
<Input {...field} />
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
})}
</div>
)}
<FormItem> <FormItem>
<FormLabel>Reference Images</FormLabel> <FormLabel>Reference Images</FormLabel>
<FormControl> <FormControl>
<div className="space-y-2"> <div className="space-y-2">
<FileDropzone onFilesSelected={setFiles} /> <FileDropzone files={files} onFilesSelected={setFiles} />
{files.length > 0 && ( {files.length > 0 && (
<ul className="list-disc pl-4 text-sm text-muted-foreground"> <div className="space-y-2">
{files.map((file, i) => ( <ul className="list-disc pl-4 text-sm text-muted-foreground">
<li key={i}>{file.name}</li> {files.map((file, i) => (
))} <li key={`${file.name}-${file.size}-${file.lastModified}-${i}`}>
</ul> {file.name}
</li>
))}
</ul>
<Button
type="button"
variant="outline"
className="h-9"
disabled={isSubmitting}
onClick={() => setFiles([])}
>
Clear files
</Button>
</div>
)} )}
</div> </div>
</FormControl> </FormControl>
@ -325,10 +330,10 @@ export function CommissionOrderForm({ types }: Props) {
. .
</div> </div>
<Button type="submit" disabled={!form.formState.isValid}> <Button type="submit" disabled={!form.formState.isValid || isSubmitting}>
Submit Request {isSubmitting ? "Submitting…" : "Submit Request"}
</Button> </Button>
</form> </form>
</Form> </Form>
) );
} }

View File

@ -1,47 +1,87 @@
"use client" "use client";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { useCallback, useRef, useState } from "react" import * as React from "react";
type Props = {
/** Current selected files (controlled) */
files: File[];
/** Called with the full next list of files */
onFilesSelected: (files: File[]) => void;
/** If true, newly added files are appended to existing files */
append?: boolean;
/** Optional limits */
accept?: string;
maxFiles?: number;
};
function defaultFileKey(f: File) {
// Good-enough de-dupe key for user selections
return `${f.name}__${f.size}__${f.lastModified}`;
}
export function FileDropzone({ export function FileDropzone({
files,
onFilesSelected, onFilesSelected,
}: { append = true,
onFilesSelected: (files: File[]) => void accept = "image/*",
}) { maxFiles = 10,
const [isDragging, setIsDragging] = useState(false) }: Props) {
const inputRef = useRef<HTMLInputElement | null>(null) const [isDragging, setIsDragging] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const handleFiles = (files: FileList | null) => { const mergeFiles = React.useCallback(
if (files) { (incoming: File[]) => {
onFilesSelected(Array.from(files)) const next = append ? [...files, ...incoming] : [...incoming];
}
} // de-dupe (same name/size/lastModified)
const map = new Map<string, File>();
for (const f of next) map.set(defaultFileKey(f), f);
const deduped = Array.from(map.values()).slice(0, maxFiles);
onFilesSelected(deduped);
// Allow selecting the same file again later (if user removes and re-adds)
if (inputRef.current) inputRef.current.value = "";
},
[append, files, maxFiles, onFilesSelected]
);
const handleFiles = React.useCallback(
(list: FileList | null) => {
if (!list) return;
const incoming = Array.from(list);
if (incoming.length === 0) return;
mergeFiles(incoming);
},
[mergeFiles]
);
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => { const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault() e.preventDefault();
setIsDragging(false) setIsDragging(false);
handleFiles(e.dataTransfer.files) handleFiles(e.dataTransfer.files);
} };
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault() e.preventDefault();
setIsDragging(true) setIsDragging(true);
} };
const handleDragLeave = () => { const handleDragLeave = () => setIsDragging(false);
setIsDragging(false)
}
const handleChange = useCallback( const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
(e: React.ChangeEvent<HTMLInputElement>) => { handleFiles(e.target.files);
handleFiles(e.target.files) };
},
[onFilesSelected]
)
return ( return (
<div <div
role="button"
tabIndex={0}
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
}}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@ -54,12 +94,19 @@ export function FileDropzone({
ref={inputRef} ref={inputRef}
type="file" type="file"
multiple multiple
accept={accept}
onChange={handleChange} onChange={handleChange}
className="hidden" className="hidden"
/> />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Drag & drop images here or click to upload Drag & drop images here or click to upload
</p> </p>
{files.length > 0 ? (
<p className="mt-2 text-xs text-muted-foreground">
Selected: {files.length} / {maxFiles}
</p>
) : null}
</div> </div>
) );
} }