Add commissions
This commit is contained in:
28
src/app/(normal)/commissions/page.tsx
Normal file
28
src/app/(normal)/commissions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/commissions/CommissionCard.tsx
Normal file
97
src/components/commissions/CommissionCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
304
src/components/commissions/CommissionOrderForm.tsx
Normal file
304
src/components/commissions/CommissionOrderForm.tsx
Normal 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 you’d 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>
|
||||
)
|
||||
}
|
||||
65
src/components/commissions/FileDropzone.tsx
Normal file
65
src/components/commissions/FileDropzone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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" },
|
||||
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal 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
167
src/components/ui/form.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal 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 }
|
||||
11
src/schemas/commissionOrder.ts
Normal file
11
src/schemas/commissionOrder.ts
Normal 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"),
|
||||
})
|
||||
48
src/utils/calculatePrice.ts
Normal file
48
src/utils/calculatePrice.ts
Normal 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]
|
||||
}
|
||||
Reference in New Issue
Block a user