Files
v2.app.gaertan.art/src/components/commissions/CommissionOrderForm.tsx

452 lines
14 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,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type {
CommissionCustomCard,
CommissionCustomCardExtra,
CommissionCustomCardOption,
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 type * 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 CommissionCustomCardWithRelations = CommissionCustomCard & {
options: (CommissionCustomCardOption & { option: CommissionOption })[];
extras: (CommissionCustomCardExtra & { extra: CommissionExtra })[];
};
type Props = {
types: CommissionTypeWithRelations[];
customCards: CommissionCustomCardWithRelations[];
};
type SelectedOption = {
id: string;
optionId: string;
option: CommissionOption;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
type SelectedExtra = {
id: string;
extraId: string;
extra: CommissionExtra;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
export function CommissionOrderForm({ types, customCards }: Props) {
const form = useForm<z.infer<typeof commissionOrderSchema>>({
resolver: zodResolver(commissionOrderSchema),
defaultValues: {
typeId: "",
customCardId: "",
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 customCardId = useWatch({ control: form.control, name: "customCardId" });
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 selectedCustomCard = useMemo(
() => customCards.find((c) => c.id === customCardId),
[customCards, customCardId]
);
const selection = useMemo<{
kind: "type" | "custom";
name: string;
options: SelectedOption[];
extras: SelectedExtra[];
} | null>(() => {
if (selectedCustomCard) {
return {
kind: "custom",
name: selectedCustomCard.name,
options: selectedCustomCard.options,
extras: selectedCustomCard.extras,
};
}
if (selectedType) {
return {
kind: "type",
name: selectedType.name,
options: selectedType.options,
extras: selectedType.extras,
};
}
return null;
}, [selectedCustomCard, selectedType]);
const selectedOption = useMemo(
() => selection?.options.find((o) => o.optionId === optionId),
[selection, optionId]
);
const selectedExtras = useMemo(
() => selection?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
[selection, 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,
customCardId: values.customCardId || 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: "",
customCardId: "",
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);
form.setValue("customCardId", "");
form.setValue("optionId", "");
form.setValue("extraIds", []);
}}
disabled={isSubmitting}
>
{type.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{customCards.length > 0 ? (
<FormField
control={form.control}
name="customCardId"
render={({ field }) => (
<FormItem>
<FormLabel>Custom requests / YCH</FormLabel>
<FormControl>
<div className="flex flex-wrap gap-2">
{customCards.map((card) => (
<Button
key={card.id}
type="button"
variant={field.value === card.id ? "default" : "outline"}
onClick={() => {
field.onChange(card.id);
form.setValue("typeId", "");
form.setValue("optionId", "");
form.setValue("extraIds", []);
}}
disabled={isSubmitting}
>
{card.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
{selection && (
<>
<FormField
control={form.control}
name="optionId"
render={({ field }) => (
<FormItem>
<FormLabel>Base Option</FormLabel>
<FormControl>
<div className="space-y-1">
{selection.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">
{selection.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>
<FormDescription>
This name will be visible on the commission status page.
</FormDescription>
<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>
<FormDescription>
Will be used for sending the invoice via paypal
</FormDescription>
<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>
<FormDescription>
Optional. But if filled out, we need handle and which platform. Currently supported are fediverse, bsky and telegram
</FormDescription>
<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>
);
}