Doing stuff

This commit is contained in:
2025-07-06 10:26:33 +02:00
parent af756e2154
commit ebb5bf9a52
15 changed files with 968 additions and 465 deletions

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
# Base image
FROM node:20
# Set working directory
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm install
# Copy the rest of the code
COPY . .
RUN npx prisma generate
# Expose the dev port
EXPOSE 3000
# Run dev server
CMD ["npm", "run", "dev"]

41
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
@ -1041,11 +1042,33 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@prisma/client": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.11.1.tgz",
"integrity": "sha512-5CLFh8QP6KxRm83pJ84jaVCeSVPQr8k0L2SEtOJHwdkS57/VQDcI/wQpGmdyOZi+D9gdNabdo8tj1Uk+w+upsQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"peerDependencies": {
"prisma": "*",
"typescript": ">=5.1.0"
},
"peerDependenciesMeta": {
"prisma": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@prisma/config": { "node_modules/@prisma/config": {
"version": "6.11.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.11.1.tgz",
"integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==", "integrity": "sha512-z6rCTQN741wxDq82cpdzx2uVykpnQIXalLhrWQSR0jlBVOxCIkz3HZnd8ern3uYTcWKfB3IpVAF7K2FU8t/8AQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"jiti": "2.4.2" "jiti": "2.4.2"
@ -1055,14 +1078,14 @@
"version": "6.11.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.11.1.tgz",
"integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==", "integrity": "sha512-lWRb/YSWu8l4Yum1UXfGLtqFzZkVS2ygkWYpgkbgMHn9XJlMITIgeMvJyX5GepChzhmxuSuiq/MY/kGFweOpGw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.11.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.11.1.tgz",
"integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==", "integrity": "sha512-6eKEcV6V8W2eZAUwX2xTktxqPM4vnx3sxz3SDtpZwjHKpC6lhOtc4vtAtFUuf5+eEqBk+dbJ9Dcaj6uQU+FNNg==",
"dev": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -1076,14 +1099,14 @@
"version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9", "version": "6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.11.1-1.f40f79ec31188888a2e33acda0ecc8fd10a853a9.tgz",
"integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==", "integrity": "sha512-swFJTOOg4tHyOM1zB/pHb3MeH0i6t7jFKn5l+ZsB23d9AQACuIRo9MouvuKGvnDogzkcjbWnXi/NvOZ0+n5Jfw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.11.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.11.1.tgz",
"integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==", "integrity": "sha512-NBYzmkXTkj9+LxNPRSndaAeALOL1Gr3tjvgRYNqruIPlZ6/ixLeuE/5boYOewant58tnaYFZ5Ne0jFBPfGXHpQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.11.1", "@prisma/debug": "6.11.1",
@ -1095,7 +1118,7 @@
"version": "6.11.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.11.1.tgz",
"integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==", "integrity": "sha512-b2Z8oV2gwvdCkFemBTFd0x4lsL4O2jLSx8lB7D+XqoFALOQZPa7eAPE1NU0Mj1V8gPHRxIsHnyUNtw2i92psUw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.11.1" "@prisma/debug": "6.11.1"
@ -5124,7 +5147,7 @@
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
@ -6107,7 +6130,7 @@
"version": "6.11.1", "version": "6.11.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.1.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.11.1.tgz",
"integrity": "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w==", "integrity": "sha512-VzJToRlV0s9Vu2bfqHiRJw73hZNCG/AyJeX+kopbu4GATTjTUdEWUteO3p4BLYoHpMS4o8pD3v6tF44BHNZI1w==",
"dev": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
@ -7168,7 +7191,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@prisma/client": "^6.11.1",
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11", "@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",

View File

@ -13,3 +13,162 @@ datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model CommissionType {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
options CommissionTypeOption[]
extras CommissionTypeExtra[]
}
model CommissionOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
description String?
types CommissionTypeOption[]
}
model CommissionExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String @unique
description String?
types CommissionTypeExtra[]
}
model CommissionTypeOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
optionId String
priceRange String?
pricePercent Float?
price Float?
type CommissionType @relation(fields: [typeId], references: [id])
option CommissionOption @relation(fields: [optionId], references: [id])
@@unique([typeId, optionId])
}
model CommissionTypeExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
typeId String
extraId String
priceRange String?
pricePercent Float?
price Float?
type CommissionType @relation(fields: [typeId], references: [id])
extra CommissionExtra @relation(fields: [extraId], references: [id])
@@unique([typeId, extraId])
}
// model User {
// id String @id @default(cuid())
// email String @unique
// name String?
// role Role @default(ADMIN)
// createdAt DateTime @default(now())
// }
// enum Role {
// ADMIN
// ARTIST
// }
// model CommissionType {
// id String @id @default(cuid())
// title String
// description String?
// basePrice Float
// deliveryEst String? // e.g. "2 weeks"
// tags String[] // e.g. shaded, sketch, full-body
// active Boolean @default(true)
// createdAt DateTime @default(now())
// CommissionRequest CommissionRequest[]
// }
// model CommissionRequest {
// id String @id @default(cuid())
// name String
// email String
// message String
// typeId String
// status RequestStatus @default(PENDING)
// createdAt DateTime @default(now())
// type CommissionType @relation(fields: [typeId], references: [id])
// }
// enum RequestStatus {
// PENDING
// ACCEPTED
// IN_PROGRESS
// DONE
// REJECTED
// }
// model Artwork {
// id String @id @default(cuid())
// title String
// imageUrl String
// description String?
// tags String[]
// formats String[]
// isPublic Boolean @default(true)
// groupId String?
// createdAt DateTime @default(now())
// group PresentationGroup? @relation(fields: [groupId], references: [id])
// }
// model PresentationGroup {
// id String @id @default(cuid())
// name String
// description String?
// createdAt DateTime @default(now())
// Artwork Artwork[]
// }
// model Preferences {
// id String @id @default(cuid())
// commissionOpen Boolean @default(true)
// defaultDelivery String? // e.g. "7 days"
// autoReplyMessage String?
// notifyByEmail Boolean @default(true)
// }
// model TOS {
// id String @id @default(cuid())
// content String // Markdown or rich text
// createdAt DateTime @default(now())
// }

View File

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

View File

@ -1,25 +1,32 @@
// components/commissions/CommissionCard.tsx
"use client" "use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { CommissionCardProps } from "@/types/commissions" import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma"
import Image from "next/image" // import { useState } from "react"
import { useState } from "react" import { Badge } from "../ui/badge"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"
export function CommissionCard({ type, options, extras, examples }: CommissionCardProps) { type CommissionTypeWithItems = CommissionType & {
const [open, setOpen] = useState(false) options: (CommissionTypeOption & {
option: CommissionOption | null
})[]
extras: (CommissionTypeExtra & {
extra: CommissionExtra | null
})[]
}
export function CommissionCard({ commission }: { commission: CommissionTypeWithItems }) {
// const [open, setOpen] = useState(false)
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Card className="flex flex-col flex-1"> <Card className="flex flex-col flex-1">
<CardHeader> <CardHeader>
<CardTitle className="text-xl font-bold">{type.name}</CardTitle> <CardTitle className="text-xl font-bold">{commission.name}</CardTitle>
<p className="text-muted-foreground text-sm">{type.description}</p> <p className="text-muted-foreground text-sm">{commission.description}</p>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col justify-start gap-4"> <CardContent className="flex flex-col justify-start gap-4">
{examples && examples.length > 0 && ( {/* {examples && examples.length > 0 && (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="text-sm underline text-muted-foreground"> <CollapsibleTrigger className="text-sm underline text-muted-foreground">
{open ? "Hide Examples" : "See Examples"} {open ? "Hide Examples" : "See Examples"}
@ -41,13 +48,20 @@ export function CommissionCard({ type, options, extras, examples }: CommissionCa
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
)} )} */}
<div> <div>
<h4 className="font-semibold">Options</h4> <h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc"> <ul className="pl-4 list-disc">
{options.map((opt) => ( {commission.options.map((option) => (
<li key={opt.key}> <li key={option.id}>
{opt.name}: {opt.price} {option.option?.name}:{" "}
{option.price
? `${option.price}`
: option.pricePercent
? `+${option.pricePercent}%`
: option.priceRange
? `${option.priceRange}`
: "Included"}
</li> </li>
))} ))}
</ul> </ul>
@ -56,28 +70,28 @@ export function CommissionCard({ type, options, extras, examples }: CommissionCa
<div> <div>
<h4 className="font-semibold">Extras</h4> <h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc"> <ul className="pl-4 list-disc">
{extras.map((extra) => ( {commission.extras.map((extra) => (
<li key={extra.key}> <li key={extra.id}>
{extra.name}:{" "} {extra.extra?.name}:{" "}
{extra.price !== undefined {extra.price
? `${extra.price}` ? `${extra.price}`
: extra.pricePercent : extra.pricePercent
? `+${extra.pricePercent}%` ? `+${extra.pricePercent}%`
: extra.priceRange : extra.priceRange
? `${extra.priceRange.replace("#", "")}` ? `${extra.priceRange}`
: "Included"} : "Included"}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
{/* <div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{extras.map((extra) => ( {commission.extras.map((extra) => (
<Badge variant="outline" key={extra.key}> <Badge variant="outline" key={extra.id}>
{extra.name} {extra.extra?.name}
</Badge> </Badge>
))} ))}
</div> */} </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -1,55 +0,0 @@
// src/app/(frontend)/commissions/CommissionForm.tsx
"use client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { CommissionFormData, commissionFormSchema } from "@/schemas/commission"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
// import { CommissionFormData, commissionFormSchema } from "./schema"
export function CommissionForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CommissionFormData>({
resolver: zodResolver(commissionFormSchema),
})
function onSubmit(data: CommissionFormData) {
console.log("Order submitted", data)
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} />
{errors.name && <p className="text-sm text-red-500">{errors.name.message}</p>}
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" {...register("email")} />
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
</div>
<div>
<Label htmlFor="type">Commission Type</Label>
<Input id="type" {...register("type")} />
{errors.type && <p className="text-sm text-red-500">{errors.type.message}</p>}
</div>
<div>
<Label htmlFor="message">Description / Message</Label>
<Textarea id="message" {...register("message")} rows={5} />
{errors.message && <p className="text-sm text-red-500">{errors.message.message}</p>}
</div>
<Button type="submit">Submit Request</Button>
</form>
)
}

View File

@ -0,0 +1,238 @@
"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 { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma"
import { commissionOrderSchema } from "@/schemas/commissionOrder"
import { calculatePrice } from "@/utils/calculatePrice"
import { zodResolver } from "@hookform/resolvers/zod"
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 })[]
}
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 price = useMemo(() => {
if (!selectedOption) return 0
const base = calculatePrice(selectedOption, 0)
const extrasSum = selectedExtras.reduce(
(sum, ext) => sum + calculatePrice(ext, base),
0
)
return base + extrasSum
}, [selectedOption, selectedExtras])
async function onSubmit(values: z.infer<typeof commissionOrderSchema>) {
console.log("Submit:", { ...values, files })
// TODO: send to server
}
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>
)}
/>
<div>
<FormLabel>Reference Images</FormLabel>
<FileDropzone onFilesSelected={(f: File[]) => setFiles(f)} />
</div>
<div className="text-xl font-semibold">
Estimated Price: {price.toFixed(2)}
</div>
<Button type="submit" disabled={!form.formState.isValid}>
Submit Request
</Button>
</form>
</Form>
)
}

View File

@ -0,0 +1,24 @@
// components/form/FileDropzone.tsx
"use client"
import { useCallback } from "react"
export function FileDropzone({
onFilesSelected,
}: {
onFilesSelected: (files: File[]) => void
}) {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
onFilesSelected(Array.from(e.target.files))
}
}, [onFilesSelected])
return (
<input
type="file"
multiple
onChange={handleChange}
className="border p-2 rounded-md"
/>
)
}

View File

@ -1,311 +1,311 @@
export const commissionTypes = { // export const commissionTypes = {
sketch: { // sketch: {
name: 'Sketch', // name: 'Sketch',
description: 'A quick sketch of a character', // description: 'A quick sketch of a character',
}, // },
flat: { // flat: {
name: 'Flat Color / Lineart', // name: 'Flat Color / Lineart',
description: 'Dunno how to describe this', // description: 'Dunno how to describe this',
}, // },
shaded: { // shaded: {
name: 'Shaded', // name: 'Shaded',
description: 'Dunno how to describe this', // description: 'Dunno how to describe this',
}, // },
refsheet: { // refsheet: {
name: 'Reference Sheet', // name: 'Reference Sheet',
description: 'A reference sheet with multiple poses', // description: 'A reference sheet with multiple poses',
} // }
} // }
export const commissionOptions = { // export const commissionOptions = {
headshot: { // headshot: {
name: 'Headshot', // name: 'Headshot',
basePrice: 10 // basePrice: 10
}, // },
halfbody: { // halfbody: {
name: 'Halfbody', // name: 'Halfbody',
basePrice: 15 // basePrice: 15
}, // },
fullbody: { // fullbody: {
name: 'Fullbody', // name: 'Fullbody',
basePrice: 20 // basePrice: 20
} // }
} // }
export const commissionExtras = { // export const commissionExtras = {
extraChar: { // extraChar: {
name: 'Extra Character', // name: 'Extra Character',
description: 'Add an extra character to the commission', // description: 'Add an extra character to the commission',
pricePercent: 75, // pricePercent: 75,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
basicBackground: { // basicBackground: {
name: 'Basic Background', // name: 'Basic Background',
description: 'Add a basic background to the commission', // description: 'Add a basic background to the commission',
pricePercent: 0, // pricePercent: 0,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
abstractBackground: { // abstractBackground: {
name: 'Abstract Background', // name: 'Abstract Background',
description: 'Add an abstract background to the commission', // description: 'Add an abstract background to the commission',
pricePercent: 0, // pricePercent: 0,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
gradientBackground: { // gradientBackground: {
name: 'Gradient Background', // name: 'Gradient Background',
description: 'Add an gradient background to the commission', // description: 'Add an gradient background to the commission',
pricePercent: 0, // pricePercent: 0,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
customBackground: { // customBackground: {
name: 'Custom Background', // name: 'Custom Background',
description: 'Add a custom background to the commission', // description: 'Add a custom background to the commission',
pricePercent: 0, // pricePercent: 0,
price: 0, // price: 0,
priceRange: "10#50" // priceRange: "10#50"
}, // },
nsfw: { // nsfw: {
name: 'NSFW', // name: 'NSFW',
description: 'Add NSFW content to the commission', // description: 'Add NSFW content to the commission',
pricePercent: 15, // pricePercent: 15,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
normalComplexity: { // normalComplexity: {
name: 'Light Complexity', // name: 'Light Complexity',
description: 'Increase complexity of the commission', // description: 'Increase complexity of the commission',
pricePercent: 15, // pricePercent: 15,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
highComplexity: { // highComplexity: {
name: 'High Complexity', // name: 'High Complexity',
description: 'Highly increase complexity of the commission', // description: 'Highly increase complexity of the commission',
pricePercent: 30, // pricePercent: 30,
price: 0, // price: 0,
priceRange: "" // priceRange: ""
}, // },
details: { // details: {
name: 'Details', // name: 'Details',
description: 'Add details to the commission (tails, eyes, ...)', // description: 'Add details to the commission (tails, eyes, ...)',
pricePercent: 0, // pricePercent: 0,
price: 0, // price: 0,
priceRange: "3#5" // priceRange: "3#5"
}, // },
shading: { // shading: {
name: 'Shading', // name: 'Shading',
description: 'Add shading to the commission', // description: 'Add shading to the commission',
pricePercent: 0, // pricePercent: 0,
price: 20, // price: 20,
priceRange: "" // priceRange: ""
} // }
} // }
export const rawCommissions = [ // export const rawCommissions = [
{ // {
type: commissionTypes.sketch, // type: commissionTypes.sketch,
options: [ // options: [
{ // {
type: commissionOptions.headshot, // type: commissionOptions.headshot,
price: commissionOptions.headshot.basePrice // price: commissionOptions.headshot.basePrice
}, // },
{ // {
type: commissionOptions.halfbody, // type: commissionOptions.halfbody,
price: commissionOptions.halfbody.basePrice // price: commissionOptions.halfbody.basePrice
}, // },
{ // {
type: commissionOptions.fullbody, // type: commissionOptions.fullbody,
price: commissionOptions.fullbody.basePrice // price: commissionOptions.fullbody.basePrice
} // }
], // ],
extras: [ // extras: [
{ // {
type: commissionExtras.extraChar, // type: commissionExtras.extraChar,
pricePercent: commissionExtras.extraChar.pricePercent // pricePercent: commissionExtras.extraChar.pricePercent
}, // },
{ // {
type: commissionExtras.basicBackground, // type: commissionExtras.basicBackground,
price: commissionExtras.basicBackground.price // price: commissionExtras.basicBackground.price
}, // },
{ // {
type: commissionExtras.abstractBackground, // type: commissionExtras.abstractBackground,
price: commissionExtras.abstractBackground.price // price: commissionExtras.abstractBackground.price
}, // },
{ // {
type: commissionExtras.gradientBackground, // type: commissionExtras.gradientBackground,
price: commissionExtras.gradientBackground.price // price: commissionExtras.gradientBackground.price
}, // },
{ // {
type: commissionExtras.customBackground, // type: commissionExtras.customBackground,
priceRange: commissionExtras.customBackground.priceRange // priceRange: commissionExtras.customBackground.priceRange
}, // },
{ // {
type: commissionExtras.nsfw, // type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent // pricePercent: commissionExtras.nsfw.pricePercent
}, // },
{ // {
type: commissionExtras.normalComplexity, // type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent // pricePercent: commissionExtras.normalComplexity.pricePercent
}, // },
{ // {
type: commissionExtras.highComplexity, // type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent // pricePercent: commissionExtras.highComplexity.pricePercent
}, // },
], // ],
examples: [ // examples: [
'/examples/sketch1.png' // '/examples/sketch1.png'
] // ]
}, // },
{ // {
type: commissionTypes.flat, // type: commissionTypes.flat,
options: [ // options: [
{ // {
type: commissionOptions.headshot, // type: commissionOptions.headshot,
price: 18 // price: 18
}, // },
{ // {
type: commissionOptions.halfbody, // type: commissionOptions.halfbody,
price: 22 // price: 22
}, // },
{ // {
type: commissionOptions.fullbody, // type: commissionOptions.fullbody,
price: 28 // price: 28
} // }
], // ],
extras: [ // extras: [
{ // {
type: commissionExtras.extraChar, // type: commissionExtras.extraChar,
pricePercent: commissionExtras.extraChar.pricePercent // pricePercent: commissionExtras.extraChar.pricePercent
}, // },
{ // {
type: commissionExtras.basicBackground, // type: commissionExtras.basicBackground,
price: commissionExtras.basicBackground.price // price: commissionExtras.basicBackground.price
}, // },
{ // {
type: commissionExtras.abstractBackground, // type: commissionExtras.abstractBackground,
price: commissionExtras.abstractBackground.price // price: commissionExtras.abstractBackground.price
}, // },
{ // {
type: commissionExtras.gradientBackground, // type: commissionExtras.gradientBackground,
price: commissionExtras.gradientBackground.price // price: commissionExtras.gradientBackground.price
}, // },
{ // {
type: commissionExtras.customBackground, // type: commissionExtras.customBackground,
priceRange: commissionExtras.customBackground.priceRange // priceRange: commissionExtras.customBackground.priceRange
}, // },
{ // {
type: commissionExtras.nsfw, // type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent // pricePercent: commissionExtras.nsfw.pricePercent
}, // },
{ // {
type: commissionExtras.normalComplexity, // type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent // pricePercent: commissionExtras.normalComplexity.pricePercent
}, // },
{ // {
type: commissionExtras.highComplexity, // type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent // pricePercent: commissionExtras.highComplexity.pricePercent
} // }
], // ],
examples: [ // examples: [
'/examples/flat1.png', // '/examples/flat1.png',
'/examples/flat2.png' // '/examples/flat2.png'
] // ]
}, // },
{ // {
type: commissionTypes.shaded, // type: commissionTypes.shaded,
options: [ // options: [
{ // {
type: commissionOptions.headshot, // type: commissionOptions.headshot,
price: 25 // price: 25
}, // },
{ // {
type: commissionOptions.halfbody, // type: commissionOptions.halfbody,
price: 40 // price: 40
}, // },
{ // {
type: commissionOptions.fullbody, // type: commissionOptions.fullbody,
price: 55 // price: 55
} // }
], // ],
extras: [ // extras: [
{ // {
type: commissionExtras.extraChar, // type: commissionExtras.extraChar,
pricePercent: commissionExtras.extraChar.pricePercent // pricePercent: commissionExtras.extraChar.pricePercent
}, // },
{ // {
type: commissionExtras.basicBackground, // type: commissionExtras.basicBackground,
price: commissionExtras.basicBackground.price // price: commissionExtras.basicBackground.price
}, // },
{ // {
type: commissionExtras.abstractBackground, // type: commissionExtras.abstractBackground,
price: commissionExtras.abstractBackground.price // price: commissionExtras.abstractBackground.price
}, // },
{ // {
type: commissionExtras.gradientBackground, // type: commissionExtras.gradientBackground,
price: commissionExtras.gradientBackground.price // price: commissionExtras.gradientBackground.price
}, // },
{ // {
type: commissionExtras.customBackground, // type: commissionExtras.customBackground,
priceRange: "20#100" // priceRange: "20#100"
}, // },
{ // {
type: commissionExtras.nsfw, // type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent // pricePercent: commissionExtras.nsfw.pricePercent
}, // },
{ // {
type: commissionExtras.normalComplexity, // type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent // pricePercent: commissionExtras.normalComplexity.pricePercent
}, // },
{ // {
type: commissionExtras.highComplexity, // type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent // pricePercent: commissionExtras.highComplexity.pricePercent
} // }
], // ],
examples: [ // examples: [
'/examples/shaded1.png' // '/examples/shaded1.png'
] // ]
}, // },
{ // {
type: commissionTypes.refsheet, // type: commissionTypes.refsheet,
options: [ // options: [
{ // {
type: commissionOptions.fullbody, // type: commissionOptions.fullbody,
price: 25 // price: 25
} // }
], // ],
extras: [ // extras: [
{ // {
type: commissionExtras.customBackground, // type: commissionExtras.customBackground,
priceRange: "20#100" // priceRange: "20#100"
}, // },
{ // {
type: commissionExtras.nsfw, // type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent // pricePercent: commissionExtras.nsfw.pricePercent
}, // },
{ // {
type: commissionExtras.normalComplexity, // type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent // pricePercent: commissionExtras.normalComplexity.pricePercent
}, // },
{ // {
type: commissionExtras.highComplexity, // type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent // pricePercent: commissionExtras.highComplexity.pricePercent
}, // },
{ // {
type: commissionExtras.details, // type: commissionExtras.details,
priceRange: commissionExtras.details.priceRange // priceRange: commissionExtras.details.priceRange
}, // },
{ // {
type: commissionExtras.shading, // type: commissionExtras.shading,
price: commissionExtras.shading.price // price: commissionExtras.shading.price
} // }
], // ],
examples: [ // examples: [
'/examples/refsheet1.png' // '/examples/refsheet1.png'
] // ]
} // }
] // ]

14
src/lib/prisma.ts Normal file
View File

@ -0,0 +1,14 @@
// import { PrismaClient } from '@/types/prisma'
// import { withAccelerate } from '@prisma/extension-accelerate'
import { PrismaClient } from "@/generated/prisma"
const globalForPrisma = global as unknown as {
prisma: PrismaClient
}
const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma

View File

@ -1,14 +1,14 @@
import { z } from "zod" // import { z } from "zod"
export const commissionOrderSchema = z.object({ // export const commissionOrderSchema = z.object({
type: z.enum(["sketch", "flat", "shaded", "refsheet"]), // type: z.enum(["sketch", "flat", "shaded", "refsheet"]),
body: z.enum(["headshot", "portrait", "waistup", "fullbody"]).optional(), // body: z.enum(["headshot", "portrait", "waistup", "fullbody"]).optional(),
extras: z.array(z.string()).optional(), // extras: z.array(z.string()).optional(),
customExtras: z.string().optional(), // customExtras: z.string().optional(),
description: z.string().min(10), // description: z.string().min(10),
contact: z.string().email("Please enter a valid email"), // contact: z.string().email("Please enter a valid email"),
referenceFiles: z.array(z.any()).optional(), // for now just accept any File[] // referenceFiles: z.array(z.any()).optional(), // for now just accept any File[]
tosAccepted: z.literal(true), // must be checked // tosAccepted: z.literal(true), // must be checked
}) // })
export type commissionOrderSchema = z.infer<typeof commissionOrderSchema> // export type commissionOrderSchema = z.infer<typeof commissionOrderSchema>

View File

@ -0,0 +1,10 @@
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(),
customerName: z.string().min(2, "Enter your name"),
customerEmail: z.string().email("Invalid email"),
message: z.string().min(5, "Please describe what you want"),
})

View File

@ -1,59 +1,59 @@
// types/commissions.ts // // types/commissions.ts
export interface CommissionType { // export interface CommissionType {
name: string // name: string
description: string // description: string
} // }
export interface CommissionOptionType { // export interface CommissionOptionType {
name: string // name: string
basePrice: number // basePrice: number
} // }
export interface CommissionOption { // export interface CommissionOption {
type: CommissionOptionType // type: CommissionOptionType
price: number // price: number
} // }
export interface CommissionExtraType { // export interface CommissionExtraType {
name: string // name: string
description: string // description: string
pricePercent?: number // pricePercent?: number
price?: number // price?: number
priceRange?: string // priceRange?: string
} // }
export interface CommissionExtra { // export interface CommissionExtra {
type: CommissionExtraType // type: CommissionExtraType
price?: number // price?: number
pricePercent?: number // pricePercent?: number
priceRange?: string // priceRange?: string
} // }
export interface Commission { // export interface Commission {
type: CommissionType // type: CommissionType
options: CommissionOption[] // options: CommissionOption[]
extras: CommissionExtra[] // extras: CommissionExtra[]
} // }
export interface CommissionCardProps { // export interface CommissionCardProps {
type: { // type: {
key: string // key: string
name: string // name: string
description: string // description: string
} // }
options: { // options: {
key: string // key: string
name: string // name: string
price: number // price: number
}[] // }[]
extras: { // extras: {
key: string // key: string
name: string // name: string
description: string // description: string
price?: number // price?: number
pricePercent?: number // pricePercent?: number
priceRange?: string // priceRange?: string
}[], // }[],
examples?: string[] // examples?: string[]
} // }

View File

@ -0,0 +1,49 @@
// export function calculatePrice(
// option: { price?: number; pricePercent?: number; priceRange?: string },
// extras: { price?: number; pricePercent?: number; priceRange?: string }[]
// ): number | [number, number] {
// const base = option.price ?? 0
// let total = base
// let hasRange = false
// let min = base
// let max = base
// for (const ext of extras) {
// if (ext.price !== undefined) {
// total += ext.price
// min += ext.price
// max += ext.price
// } else if (ext.pricePercent !== undefined) {
// const delta = base * (ext.pricePercent / 100)
// total += delta
// min += delta
// max += delta
// } else if (ext.priceRange) {
// const [rMin, rMax] = ext.priceRange.split("").map(Number)
// hasRange = true
// min += rMin
// max += rMax
// }
// }
// return hasRange ? [min, max] : total
// }
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
}