Add commissions

This commit is contained in:
2025-12-24 01:08:17 +01:00
parent 296e8a1787
commit e285e7b9af
15 changed files with 964 additions and 1 deletions

View File

@ -0,0 +1,28 @@
import { CommissionCard } from "@/components/commissions/CommissionCard";
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
import { prisma } from "@/lib/prisma";
export default async function CommissionsPage() {
const commissions = await prisma.commissionType.findMany({
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
return (
<div className="container py-10 space-y-10">
<h1 className="text-3xl font-bold">Commission Pricing</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
{commissions.map((commission) => (
<CommissionCard key={commission.id} commission={commission} />
))}
</div>
<hr />
<h2 className="text-2xl font-semibold">Request a Commission</h2>
<CommissionOrderForm types={commissions} />
</div>
);
}

View File

@ -0,0 +1,97 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
type CommissionTypeWithItems = CommissionType & {
options: (CommissionTypeOption & {
option: CommissionOption | null
})[]
extras: (CommissionTypeExtra & {
extra: CommissionExtra | null
})[]
}
export function CommissionCard({ commission }: { commission: CommissionTypeWithItems }) {
// const [open, setOpen] = useState(false)
return (
<div className="flex flex-col h-full">
<Card className="flex flex-col flex-1">
<CardHeader>
<CardTitle className="text-xl font-bold">{commission.name}</CardTitle>
<p className="text-muted-foreground text-sm">{commission.description}</p>
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
{/* {examples && examples.length > 0 && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="text-sm underline text-muted-foreground">
{open ? "Hide Examples" : "See Examples"}
</CollapsibleTrigger>
<CollapsibleContent asChild>
<div className="overflow-hidden transition-all data-[state=closed]:max-h-0 data-[state=open]:max-h-[300px]">
<div className="flex gap-2 mt-2 overflow-x-auto">
{examples.map((src, idx) => (
<Image
key={src + idx}
src={src}
width={100}
height={100}
alt={`${type.name} example ${idx + 1}`}
className="h-24 w-auto rounded border"
/>
))}
</div>
</div>
</CollapsibleContent>
</Collapsible>
)} */}
<div>
<h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc">
{commission.options.map((option) => (
<li key={option.id}>
{option.option?.name}:{" "}
{option.price
? `${option.price}`
: option.pricePercent
? `+${option.pricePercent}%`
: option.priceRange
? `${option.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc">
{commission.extras.map((extra) => (
<li key={extra.id}>
{extra.extra?.name}:{" "}
{extra.price
? `${extra.price}`
: extra.pricePercent
? `+${extra.pricePercent}%`
: extra.priceRange
? `${extra.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
{/* <div className="flex flex-wrap gap-2">
{commission.extras.map((extra) => (
<Badge variant="outline" key={extra.id}>
{extra.extra?.name}
</Badge>
))}
</div> */}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,304 @@
"use client"
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 * 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: "",
message: "",
},
})
const [files, setFiles] = useState<File[]>([])
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>) {
const { customFields, ...rest } = values
console.log("Submit:", { ...rest, customFields, files })
}
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)}
>
{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)}
/>
{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}
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} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="customerEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="jane@example.com" {...field} />
</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}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{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} />
{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>
</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}>
Submit Request
</Button>
</form>
</Form>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { cn } from "@/lib/utils"
import { useCallback, useRef, useState } from "react"
export function FileDropzone({
onFilesSelected,
}: {
onFilesSelected: (files: File[]) => void
}) {
const [isDragging, setIsDragging] = useState(false)
const inputRef = useRef<HTMLInputElement | null>(null)
const handleFiles = (files: FileList | null) => {
if (files) {
onFilesSelected(Array.from(files))
}
}
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = () => {
setIsDragging(false)
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
handleFiles(e.target.files)
},
[onFilesSelected]
)
return (
<div
onClick={() => inputRef.current?.click()}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
"w-full border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors",
isDragging ? "border-primary bg-muted" : "border-muted-foreground/30"
)}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleChange}
className="hidden"
/>
<p className="text-sm text-muted-foreground">
Drag & drop images here or click to upload
</p>
</div>
)
}

View File

@ -11,6 +11,7 @@ const links = [
{ href: "/", label: "Home" },
{ href: "/artworks", label: "Portfolio" },
{ href: "/artworks/animalstudies", label: "Animal Studies" },
{ href: "/commissions", label: "Commissions" },
// { href: "/portfolio/artfight", label: "Artfight" },
// { href: "/portfolio/minis", label: "Miniatures" },
// { href: "/commissions", label: "Commissions" },

View File

@ -0,0 +1,33 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

167
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@ -0,0 +1,11 @@
import * as z from "zod/v4"
export const commissionOrderSchema = z.object({
typeId: z.string().min(1, "Please select a type"),
optionId: z.string().min(1, "Please choose a base option"),
extraIds: z.array(z.string()).optional(),
customFields: z.record(z.string(), z.any()).optional(),
customerName: z.string().min(2, "Enter your name"),
customerEmail: z.email("Invalid email"),
message: z.string().min(5, "Please describe what you want"),
})

View File

@ -0,0 +1,48 @@
type PriceSource = {
price?: number | null
pricePercent?: number | null
priceRange?: string | null
}
export function calculatePrice(source: PriceSource, base: number): number {
if (source.price != null) return source.price
if (source.pricePercent != null) return base * (source.pricePercent / 100)
if (source.priceRange) {
const parts = source.priceRange.split("").map(Number)
const max = Math.max(...parts)
return isNaN(max) ? 0 : max
}
return 0
}
export function calculatePriceRange(
baseSource: PriceSource | undefined,
extras: PriceSource[]
): [number, number] {
if (!baseSource) return [0, 0]
const base = calculatePrice(baseSource, 0)
let minExtra = 0
let maxExtra = 0
for (const extra of extras) {
if (extra.price != null) {
minExtra += extra.price
maxExtra += extra.price
} else if (extra.pricePercent != null) {
const val = base * (extra.pricePercent / 100)
minExtra += val
maxExtra += val
} else if (extra.priceRange) {
const [minStr, maxStr] = extra.priceRange.split("")
const min = Number(minStr)
const max = Number(maxStr)
if (!isNaN(min)) minExtra += min
if (!isNaN(max)) maxExtra += max
}
}
return [base + minExtra, base + maxExtra]
}