Add visible feedback and error handling to commission requests
This commit is contained in:
@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -9,29 +9,37 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { CommissionCustomInput, CommissionExtra, CommissionOption, CommissionType, CommissionTypeCustomInput, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
||||
import { commissionOrderSchema } from "@/schemas/commissionOrder"
|
||||
import { calculatePriceRange } from "@/utils/calculatePrice"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import "dotenv/config"
|
||||
import Link from "next/link"
|
||||
import { useMemo, useState } from "react"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import * as z from "zod/v4"
|
||||
import { FileDropzone } from "./FileDropzone"
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
CommissionCustomInput,
|
||||
CommissionExtra,
|
||||
CommissionOption,
|
||||
CommissionType,
|
||||
CommissionTypeCustomInput,
|
||||
CommissionTypeExtra,
|
||||
CommissionTypeOption,
|
||||
} from "@/generated/prisma/client";
|
||||
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 & {
|
||||
options: (CommissionTypeOption & { option: CommissionOption })[]
|
||||
extras: (CommissionTypeExtra & { extra: CommissionExtra })[]
|
||||
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[]
|
||||
}
|
||||
options: (CommissionTypeOption & { option: CommissionOption })[];
|
||||
extras: (CommissionTypeExtra & { extra: CommissionExtra })[];
|
||||
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
types: CommissionTypeWithRelations[]
|
||||
}
|
||||
types: CommissionTypeWithRelations[];
|
||||
};
|
||||
|
||||
export function CommissionOrderForm({ types }: Props) {
|
||||
const form = useForm<z.infer<typeof commissionOrderSchema>>({
|
||||
@ -45,50 +53,71 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
customerSocials: "",
|
||||
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 optionId = useWatch({ control: form.control, name: "optionId" })
|
||||
const extraIds = useWatch({ control: form.control, name: "extraIds" })
|
||||
const typeId = useWatch({ control: form.control, name: "typeId" });
|
||||
const optionId = useWatch({ control: form.control, name: "optionId" });
|
||||
const extraIds = useWatch({ control: form.control, name: "extraIds" });
|
||||
|
||||
const selectedType = useMemo(
|
||||
() => types.find((t) => t.id === typeId),
|
||||
[types, typeId]
|
||||
)
|
||||
const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => selectedType?.options.find((o) => o.optionId === optionId),
|
||||
[selectedType, optionId]
|
||||
)
|
||||
);
|
||||
|
||||
const selectedExtras = useMemo(
|
||||
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
||||
[selectedType, extraIds]
|
||||
)
|
||||
);
|
||||
|
||||
const [minPrice, maxPrice] = useMemo(() => {
|
||||
return calculatePriceRange(selectedOption, selectedExtras)
|
||||
}, [selectedOption, selectedExtras])
|
||||
return calculatePriceRange(selectedOption, selectedExtras);
|
||||
}, [selectedOption, selectedExtras]);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
|
||||
const payload = {
|
||||
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
|
||||
};
|
||||
setIsSubmitting(true);
|
||||
|
||||
const res = await submitCommissionRequest({
|
||||
payload,
|
||||
files,
|
||||
});
|
||||
try {
|
||||
const payload = {
|
||||
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! I’ll 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 (
|
||||
@ -108,6 +137,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
type="button"
|
||||
variant={field.value === type.id ? "default" : "outline"}
|
||||
onClick={() => field.onChange(type.id)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{type.name}
|
||||
</Button>
|
||||
@ -136,6 +166,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
checked={field.value === opt.optionId}
|
||||
value={opt.optionId}
|
||||
onChange={() => field.onChange(opt.optionId)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{opt.option.name}
|
||||
</label>
|
||||
@ -155,21 +186,19 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
<FormLabel>Extras</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-1">
|
||||
{selectedType?.extras.map((ext) => (
|
||||
{selectedType.extras.map((ext) => (
|
||||
<label key={ext.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value?.includes(ext.extraId) ?? false}
|
||||
value={ext.extraId}
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked
|
||||
const newSet = new Set(field.value ?? [])
|
||||
if (checked) {
|
||||
newSet.add(ext.extraId)
|
||||
} else {
|
||||
newSet.delete(ext.extraId)
|
||||
}
|
||||
field.onChange(Array.from(newSet))
|
||||
const checked = e.target.checked;
|
||||
const newSet = new Set(field.value ?? []);
|
||||
if (checked) newSet.add(ext.extraId);
|
||||
else newSet.delete(ext.extraId);
|
||||
field.onChange(Array.from(newSet));
|
||||
}}
|
||||
/>
|
||||
{ext.extra.name}
|
||||
@ -184,8 +213,6 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@ -194,12 +221,13 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
<FormItem>
|
||||
<FormLabel>Your Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Jane Doe" {...field} />
|
||||
<Input placeholder="Jane Doe" {...field} disabled={isSubmitting} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customerEmail"
|
||||
@ -207,12 +235,17 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<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>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customerSocials"
|
||||
@ -220,7 +253,11 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
<FormItem>
|
||||
<FormLabel>Socials</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)" {...field} />
|
||||
<Input
|
||||
placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)"
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -239,6 +276,7 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
placeholder="Describe what you’d like drawn..."
|
||||
rows={4}
|
||||
{...field}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<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>
|
||||
<FormLabel>Reference Images</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-2">
|
||||
<FileDropzone onFilesSelected={setFiles} />
|
||||
<FileDropzone files={files} onFilesSelected={setFiles} />
|
||||
|
||||
{files.length > 0 && (
|
||||
<ul className="list-disc pl-4 text-sm text-muted-foreground">
|
||||
{files.map((file, i) => (
|
||||
<li key={i}>{file.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="space-y-2">
|
||||
<ul className="list-disc pl-4 text-sm text-muted-foreground">
|
||||
{files.map((file, i) => (
|
||||
<li key={`${file.name}-${file.size}-${file.lastModified}-${i}`}>
|
||||
{file.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-9"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setFiles([])}
|
||||
>
|
||||
Clear files
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
@ -325,10 +330,10 @@ export function CommissionOrderForm({ types }: Props) {
|
||||
.
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={!form.formState.isValid}>
|
||||
Submit Request
|
||||
<Button type="submit" disabled={!form.formState.isValid || isSubmitting}>
|
||||
{isSubmitting ? "Submitting…" : "Submit Request"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user