340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
"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! 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 (
|
||
<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="Jane Doe" {...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 you’d 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>
|
||
);
|
||
}
|