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

View File

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

View File

@ -13,3 +13,162 @@ datasource db {
provider = "postgresql"
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 { generateMockCommissions } from "@/utils/generateMockCommissions";
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
import prisma from "@/lib/prisma";
export default function CommissionsPage() {
const commissions = generateMockCommissions()
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" } },
},
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((data) => (
<CommissionCard key={data.type.key} {...data} />
{commissions.map((commission) => (
<CommissionCard key={commission.id} commission={commission} />
))}
</div>
<hr />
<h2 className="text-2xl font-semibold">Request a Commission</h2>
{/* <CommissionForm /> */}
<CommissionOrderForm types={commissions} />
</div>
);
}

View File

@ -1,25 +1,32 @@
// components/commissions/CommissionCard.tsx
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { CommissionCardProps } from "@/types/commissions"
import Image from "next/image"
import { useState } from "react"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma"
// import { useState } from "react"
import { Badge } from "../ui/badge"
export function CommissionCard({ type, options, extras, examples }: CommissionCardProps) {
const [open, setOpen] = useState(false)
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">{type.name}</CardTitle>
<p className="text-muted-foreground text-sm">{type.description}</p>
<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 && (
{/* {examples && examples.length > 0 && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="text-sm underline text-muted-foreground">
{open ? "Hide Examples" : "See Examples"}
@ -41,13 +48,20 @@ export function CommissionCard({ type, options, extras, examples }: CommissionCa
</div>
</CollapsibleContent>
</Collapsible>
)}
)} */}
<div>
<h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc">
{options.map((opt) => (
<li key={opt.key}>
{opt.name}: {opt.price}
{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>
@ -56,28 +70,28 @@ export function CommissionCard({ type, options, extras, examples }: CommissionCa
<div>
<h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc">
{extras.map((extra) => (
<li key={extra.key}>
{extra.name}:{" "}
{extra.price !== undefined
{commission.extras.map((extra) => (
<li key={extra.id}>
{extra.extra?.name}:{" "}
{extra.price
? `${extra.price}`
: extra.pricePercent
? `+${extra.pricePercent}%`
: extra.priceRange
? `${extra.priceRange.replace("#", "")}`
? `${extra.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
{/* <div className="flex flex-wrap gap-2">
{extras.map((extra) => (
<Badge variant="outline" key={extra.key}>
{extra.name}
<div className="flex flex-wrap gap-2">
{commission.extras.map((extra) => (
<Badge variant="outline" key={extra.id}>
{extra.extra?.name}
</Badge>
))}
</div> */}
</div>
</CardContent>
</Card>
</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 = {
sketch: {
name: 'Sketch',
description: 'A quick sketch of a character',
},
flat: {
name: 'Flat Color / Lineart',
description: 'Dunno how to describe this',
},
shaded: {
name: 'Shaded',
description: 'Dunno how to describe this',
},
refsheet: {
name: 'Reference Sheet',
description: 'A reference sheet with multiple poses',
}
}
// export const commissionTypes = {
// sketch: {
// name: 'Sketch',
// description: 'A quick sketch of a character',
// },
// flat: {
// name: 'Flat Color / Lineart',
// description: 'Dunno how to describe this',
// },
// shaded: {
// name: 'Shaded',
// description: 'Dunno how to describe this',
// },
// refsheet: {
// name: 'Reference Sheet',
// description: 'A reference sheet with multiple poses',
// }
// }
export const commissionOptions = {
headshot: {
name: 'Headshot',
basePrice: 10
},
halfbody: {
name: 'Halfbody',
basePrice: 15
},
fullbody: {
name: 'Fullbody',
basePrice: 20
}
}
// export const commissionOptions = {
// headshot: {
// name: 'Headshot',
// basePrice: 10
// },
// halfbody: {
// name: 'Halfbody',
// basePrice: 15
// },
// fullbody: {
// name: 'Fullbody',
// basePrice: 20
// }
// }
export const commissionExtras = {
extraChar: {
name: 'Extra Character',
description: 'Add an extra character to the commission',
pricePercent: 75,
price: 0,
priceRange: ""
},
basicBackground: {
name: 'Basic Background',
description: 'Add a basic background to the commission',
pricePercent: 0,
price: 0,
priceRange: ""
},
abstractBackground: {
name: 'Abstract Background',
description: 'Add an abstract background to the commission',
pricePercent: 0,
price: 0,
priceRange: ""
},
gradientBackground: {
name: 'Gradient Background',
description: 'Add an gradient background to the commission',
pricePercent: 0,
price: 0,
priceRange: ""
},
customBackground: {
name: 'Custom Background',
description: 'Add a custom background to the commission',
pricePercent: 0,
price: 0,
priceRange: "10#50"
},
nsfw: {
name: 'NSFW',
description: 'Add NSFW content to the commission',
pricePercent: 15,
price: 0,
priceRange: ""
},
normalComplexity: {
name: 'Light Complexity',
description: 'Increase complexity of the commission',
pricePercent: 15,
price: 0,
priceRange: ""
},
highComplexity: {
name: 'High Complexity',
description: 'Highly increase complexity of the commission',
pricePercent: 30,
price: 0,
priceRange: ""
},
details: {
name: 'Details',
description: 'Add details to the commission (tails, eyes, ...)',
pricePercent: 0,
price: 0,
priceRange: "3#5"
},
shading: {
name: 'Shading',
description: 'Add shading to the commission',
pricePercent: 0,
price: 20,
priceRange: ""
}
}
// export const commissionExtras = {
// extraChar: {
// name: 'Extra Character',
// description: 'Add an extra character to the commission',
// pricePercent: 75,
// price: 0,
// priceRange: ""
// },
// basicBackground: {
// name: 'Basic Background',
// description: 'Add a basic background to the commission',
// pricePercent: 0,
// price: 0,
// priceRange: ""
// },
// abstractBackground: {
// name: 'Abstract Background',
// description: 'Add an abstract background to the commission',
// pricePercent: 0,
// price: 0,
// priceRange: ""
// },
// gradientBackground: {
// name: 'Gradient Background',
// description: 'Add an gradient background to the commission',
// pricePercent: 0,
// price: 0,
// priceRange: ""
// },
// customBackground: {
// name: 'Custom Background',
// description: 'Add a custom background to the commission',
// pricePercent: 0,
// price: 0,
// priceRange: "10#50"
// },
// nsfw: {
// name: 'NSFW',
// description: 'Add NSFW content to the commission',
// pricePercent: 15,
// price: 0,
// priceRange: ""
// },
// normalComplexity: {
// name: 'Light Complexity',
// description: 'Increase complexity of the commission',
// pricePercent: 15,
// price: 0,
// priceRange: ""
// },
// highComplexity: {
// name: 'High Complexity',
// description: 'Highly increase complexity of the commission',
// pricePercent: 30,
// price: 0,
// priceRange: ""
// },
// details: {
// name: 'Details',
// description: 'Add details to the commission (tails, eyes, ...)',
// pricePercent: 0,
// price: 0,
// priceRange: "3#5"
// },
// shading: {
// name: 'Shading',
// description: 'Add shading to the commission',
// pricePercent: 0,
// price: 20,
// priceRange: ""
// }
// }
export const rawCommissions = [
{
type: commissionTypes.sketch,
options: [
{
type: commissionOptions.headshot,
price: commissionOptions.headshot.basePrice
},
{
type: commissionOptions.halfbody,
price: commissionOptions.halfbody.basePrice
},
{
type: commissionOptions.fullbody,
price: commissionOptions.fullbody.basePrice
}
],
extras: [
{
type: commissionExtras.extraChar,
pricePercent: commissionExtras.extraChar.pricePercent
},
{
type: commissionExtras.basicBackground,
price: commissionExtras.basicBackground.price
},
{
type: commissionExtras.abstractBackground,
price: commissionExtras.abstractBackground.price
},
{
type: commissionExtras.gradientBackground,
price: commissionExtras.gradientBackground.price
},
{
type: commissionExtras.customBackground,
priceRange: commissionExtras.customBackground.priceRange
},
{
type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent
},
{
type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent
},
{
type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent
},
],
examples: [
'/examples/sketch1.png'
]
},
{
type: commissionTypes.flat,
options: [
{
type: commissionOptions.headshot,
price: 18
},
{
type: commissionOptions.halfbody,
price: 22
},
{
type: commissionOptions.fullbody,
price: 28
}
],
extras: [
{
type: commissionExtras.extraChar,
pricePercent: commissionExtras.extraChar.pricePercent
},
{
type: commissionExtras.basicBackground,
price: commissionExtras.basicBackground.price
},
{
type: commissionExtras.abstractBackground,
price: commissionExtras.abstractBackground.price
},
{
type: commissionExtras.gradientBackground,
price: commissionExtras.gradientBackground.price
},
{
type: commissionExtras.customBackground,
priceRange: commissionExtras.customBackground.priceRange
},
{
type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent
},
{
type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent
},
{
type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent
}
],
examples: [
'/examples/flat1.png',
'/examples/flat2.png'
]
},
{
type: commissionTypes.shaded,
options: [
{
type: commissionOptions.headshot,
price: 25
},
{
type: commissionOptions.halfbody,
price: 40
},
{
type: commissionOptions.fullbody,
price: 55
}
],
extras: [
{
type: commissionExtras.extraChar,
pricePercent: commissionExtras.extraChar.pricePercent
},
{
type: commissionExtras.basicBackground,
price: commissionExtras.basicBackground.price
},
{
type: commissionExtras.abstractBackground,
price: commissionExtras.abstractBackground.price
},
{
type: commissionExtras.gradientBackground,
price: commissionExtras.gradientBackground.price
},
{
type: commissionExtras.customBackground,
priceRange: "20#100"
},
{
type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent
},
{
type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent
},
{
type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent
}
],
examples: [
'/examples/shaded1.png'
]
},
{
type: commissionTypes.refsheet,
options: [
{
type: commissionOptions.fullbody,
price: 25
}
],
extras: [
{
type: commissionExtras.customBackground,
priceRange: "20#100"
},
{
type: commissionExtras.nsfw,
pricePercent: commissionExtras.nsfw.pricePercent
},
{
type: commissionExtras.normalComplexity,
pricePercent: commissionExtras.normalComplexity.pricePercent
},
{
type: commissionExtras.highComplexity,
pricePercent: commissionExtras.highComplexity.pricePercent
},
{
type: commissionExtras.details,
priceRange: commissionExtras.details.priceRange
},
{
type: commissionExtras.shading,
price: commissionExtras.shading.price
}
],
examples: [
'/examples/refsheet1.png'
]
}
]
// export const rawCommissions = [
// {
// type: commissionTypes.sketch,
// options: [
// {
// type: commissionOptions.headshot,
// price: commissionOptions.headshot.basePrice
// },
// {
// type: commissionOptions.halfbody,
// price: commissionOptions.halfbody.basePrice
// },
// {
// type: commissionOptions.fullbody,
// price: commissionOptions.fullbody.basePrice
// }
// ],
// extras: [
// {
// type: commissionExtras.extraChar,
// pricePercent: commissionExtras.extraChar.pricePercent
// },
// {
// type: commissionExtras.basicBackground,
// price: commissionExtras.basicBackground.price
// },
// {
// type: commissionExtras.abstractBackground,
// price: commissionExtras.abstractBackground.price
// },
// {
// type: commissionExtras.gradientBackground,
// price: commissionExtras.gradientBackground.price
// },
// {
// type: commissionExtras.customBackground,
// priceRange: commissionExtras.customBackground.priceRange
// },
// {
// type: commissionExtras.nsfw,
// pricePercent: commissionExtras.nsfw.pricePercent
// },
// {
// type: commissionExtras.normalComplexity,
// pricePercent: commissionExtras.normalComplexity.pricePercent
// },
// {
// type: commissionExtras.highComplexity,
// pricePercent: commissionExtras.highComplexity.pricePercent
// },
// ],
// examples: [
// '/examples/sketch1.png'
// ]
// },
// {
// type: commissionTypes.flat,
// options: [
// {
// type: commissionOptions.headshot,
// price: 18
// },
// {
// type: commissionOptions.halfbody,
// price: 22
// },
// {
// type: commissionOptions.fullbody,
// price: 28
// }
// ],
// extras: [
// {
// type: commissionExtras.extraChar,
// pricePercent: commissionExtras.extraChar.pricePercent
// },
// {
// type: commissionExtras.basicBackground,
// price: commissionExtras.basicBackground.price
// },
// {
// type: commissionExtras.abstractBackground,
// price: commissionExtras.abstractBackground.price
// },
// {
// type: commissionExtras.gradientBackground,
// price: commissionExtras.gradientBackground.price
// },
// {
// type: commissionExtras.customBackground,
// priceRange: commissionExtras.customBackground.priceRange
// },
// {
// type: commissionExtras.nsfw,
// pricePercent: commissionExtras.nsfw.pricePercent
// },
// {
// type: commissionExtras.normalComplexity,
// pricePercent: commissionExtras.normalComplexity.pricePercent
// },
// {
// type: commissionExtras.highComplexity,
// pricePercent: commissionExtras.highComplexity.pricePercent
// }
// ],
// examples: [
// '/examples/flat1.png',
// '/examples/flat2.png'
// ]
// },
// {
// type: commissionTypes.shaded,
// options: [
// {
// type: commissionOptions.headshot,
// price: 25
// },
// {
// type: commissionOptions.halfbody,
// price: 40
// },
// {
// type: commissionOptions.fullbody,
// price: 55
// }
// ],
// extras: [
// {
// type: commissionExtras.extraChar,
// pricePercent: commissionExtras.extraChar.pricePercent
// },
// {
// type: commissionExtras.basicBackground,
// price: commissionExtras.basicBackground.price
// },
// {
// type: commissionExtras.abstractBackground,
// price: commissionExtras.abstractBackground.price
// },
// {
// type: commissionExtras.gradientBackground,
// price: commissionExtras.gradientBackground.price
// },
// {
// type: commissionExtras.customBackground,
// priceRange: "20#100"
// },
// {
// type: commissionExtras.nsfw,
// pricePercent: commissionExtras.nsfw.pricePercent
// },
// {
// type: commissionExtras.normalComplexity,
// pricePercent: commissionExtras.normalComplexity.pricePercent
// },
// {
// type: commissionExtras.highComplexity,
// pricePercent: commissionExtras.highComplexity.pricePercent
// }
// ],
// examples: [
// '/examples/shaded1.png'
// ]
// },
// {
// type: commissionTypes.refsheet,
// options: [
// {
// type: commissionOptions.fullbody,
// price: 25
// }
// ],
// extras: [
// {
// type: commissionExtras.customBackground,
// priceRange: "20#100"
// },
// {
// type: commissionExtras.nsfw,
// pricePercent: commissionExtras.nsfw.pricePercent
// },
// {
// type: commissionExtras.normalComplexity,
// pricePercent: commissionExtras.normalComplexity.pricePercent
// },
// {
// type: commissionExtras.highComplexity,
// pricePercent: commissionExtras.highComplexity.pricePercent
// },
// {
// type: commissionExtras.details,
// priceRange: commissionExtras.details.priceRange
// },
// {
// type: commissionExtras.shading,
// price: commissionExtras.shading.price
// }
// ],
// examples: [
// '/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({
type: z.enum(["sketch", "flat", "shaded", "refsheet"]),
body: z.enum(["headshot", "portrait", "waistup", "fullbody"]).optional(),
extras: z.array(z.string()).optional(),
customExtras: z.string().optional(),
description: z.string().min(10),
contact: z.string().email("Please enter a valid email"),
referenceFiles: z.array(z.any()).optional(), // for now just accept any File[]
tosAccepted: z.literal(true), // must be checked
})
// export const commissionOrderSchema = z.object({
// type: z.enum(["sketch", "flat", "shaded", "refsheet"]),
// body: z.enum(["headshot", "portrait", "waistup", "fullbody"]).optional(),
// extras: z.array(z.string()).optional(),
// customExtras: z.string().optional(),
// description: z.string().min(10),
// contact: z.string().email("Please enter a valid email"),
// referenceFiles: z.array(z.any()).optional(), // for now just accept any File[]
// 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 {
name: string
description: string
}
// export interface CommissionType {
// name: string
// description: string
// }
export interface CommissionOptionType {
name: string
basePrice: number
}
// export interface CommissionOptionType {
// name: string
// basePrice: number
// }
export interface CommissionOption {
type: CommissionOptionType
price: number
}
// export interface CommissionOption {
// type: CommissionOptionType
// price: number
// }
export interface CommissionExtraType {
name: string
description: string
pricePercent?: number
price?: number
priceRange?: string
}
// export interface CommissionExtraType {
// name: string
// description: string
// pricePercent?: number
// price?: number
// priceRange?: string
// }
export interface CommissionExtra {
type: CommissionExtraType
price?: number
pricePercent?: number
priceRange?: string
}
// export interface CommissionExtra {
// type: CommissionExtraType
// price?: number
// pricePercent?: number
// priceRange?: string
// }
export interface Commission {
type: CommissionType
options: CommissionOption[]
extras: CommissionExtra[]
}
// export interface Commission {
// type: CommissionType
// options: CommissionOption[]
// extras: CommissionExtra[]
// }
export interface CommissionCardProps {
type: {
key: string
name: string
description: string
}
options: {
key: string
name: string
price: number
}[]
extras: {
key: string
name: string
description: string
price?: number
pricePercent?: number
priceRange?: string
}[],
examples?: string[]
}
// export interface CommissionCardProps {
// type: {
// key: string
// name: string
// description: string
// }
// options: {
// key: string
// name: string
// price: number
// }[]
// extras: {
// key: string
// name: string
// description: string
// price?: number
// pricePercent?: number
// priceRange?: 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
}