Files
v2.app.gaertan.art/src/components/commissions/CommissionOrderForm.tsx
2026-01-03 16:17:39 +01:00

340 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { submitCommissionRequest } from "@/actions/commissions/submitCommissionRequest";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
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 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 })[];
};
type Props = {
types: CommissionTypeWithRelations[];
};
export function CommissionOrderForm({ types }: Props) {
const form = useForm<z.infer<typeof commissionOrderSchema>>({
resolver: zodResolver(commissionOrderSchema),
defaultValues: {
typeId: "",
optionId: "",
extraIds: [],
customerName: "",
customerEmail: "",
customerSocials: "",
message: "",
},
});
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 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]);
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
setIsSubmitting(true);
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,
};
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="typeId"
render={({ field }) => (
<FormItem>
<FormLabel>Choose a commission type</FormLabel>
<FormControl>
<div className="flex flex-wrap gap-2">
{types.map((type) => (
<Button
key={type.id}
type="button"
variant={field.value === type.id ? "default" : "outline"}
onClick={() => field.onChange(type.id)}
disabled={isSubmitting}
>
{type.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedType && (
<>
<FormField
control={form.control}
name="optionId"
render={({ field }) => (
<FormItem>
<FormLabel>Base Option</FormLabel>
<FormControl>
<div className="space-y-1">
{selectedType.options.map((opt) => (
<label key={opt.id} className="flex items-center gap-2">
<input
type="radio"
checked={field.value === opt.optionId}
value={opt.optionId}
onChange={() => field.onChange(opt.optionId)}
disabled={isSubmitting}
/>
{opt.option.name}
</label>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="extraIds"
render={({ field }) => (
<FormItem>
<FormLabel>Extras</FormLabel>
<FormControl>
<div className="space-y-1">
{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));
}}
/>
{ext.extra.name}
</label>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="customerName"
render={({ field }) => (
<FormItem>
<FormLabel>Your Name</FormLabel>
<FormControl>
<Input placeholder="Nickname, real name, how you want to be called..." {...field} disabled={isSubmitting} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="E-Mail address for invoice and/or contact"
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerSocials"
render={({ field }) => (
<FormItem>
<FormLabel>Socials</FormLabel>
<FormControl>
<Input
placeholder="Alternative for contact (telegram, bsky, fediverse/mastodon)"
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Project Details</FormLabel>
<FormControl>
<Textarea
placeholder="Describe what youd like drawn..."
rows={4}
{...field}
disabled={isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormItem>
<FormLabel>Reference Images</FormLabel>
<FormControl>
<div className="space-y-2">
<FileDropzone files={files} onFilesSelected={setFiles} />
{files.length > 0 && (
<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>
</FormItem>
<div className="text-lg font-semibold">
Estimated Price:{" "}
{minPrice === maxPrice
? `${minPrice.toFixed(2)}`
: `${minPrice.toFixed(2)} ${maxPrice.toFixed(2)}`}
</div>
<div className="text-muted-foreground">
By submitting this form, you agree to our{" "}
<Link href="/tos" className="underline">
Terms of Service
</Link>
.
</div>
<Button type="submit" disabled={!form.formState.isValid || isSubmitting}>
{isSubmitting ? "Submitting…" : "Submit Request"}
</Button>
</form>
</Form>
);
}