Add custom YCH typs for commission page

This commit is contained in:
2026-02-01 16:08:08 +01:00
parent 1940867519
commit aa95635e3e
6 changed files with 364 additions and 14 deletions

View File

@ -4,6 +4,7 @@ import { z } from "zod";
const submitPayloadSchema = z.object({
typeId: z.string().optional().nullable(),
customCardId: z.string().optional().nullable(),
optionId: z.string().optional().nullable(),
extraIds: z.array(z.string()).default([]),
@ -11,6 +12,23 @@ const submitPayloadSchema = z.object({
customerEmail: z.string().email(),
customerSocials: z.string().optional().nullable(),
message: z.string().min(1),
}).superRefine((data, ctx) => {
const hasType = Boolean(data.typeId);
const hasCustom = Boolean(data.customCardId);
if (!hasType && !hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Missing commission type or custom card",
});
}
if (hasType && hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Only one of typeId or customCardId is allowed",
});
}
});
export type SubmitCommissionPayload = z.infer<typeof submitPayloadSchema>;

View File

@ -1,4 +1,5 @@
import { CommissionCard } from "@/components/commissions/CommissionCard";
import { CommissionCustomCard } from "@/components/commissions/CommissionCustomCard";
import CommissionGuidelines from "@/components/commissions/CommissionGuidelines";
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
import { Button } from "@/components/ui/button";
@ -13,7 +14,7 @@ import { prisma } from "@/lib/prisma";
import Image from "next/image";
export default async function CommissionsPage() {
const [commissions, guidelines] = await Promise.all([
const [commissions, customCards, guidelines] = await Promise.all([
prisma.commissionType.findMany({
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
@ -22,6 +23,14 @@ export default async function CommissionsPage() {
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}),
prisma.commissionCustomCard.findMany({
where: { isVisible: true },
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
}),
prisma.commissionGuidelines.findFirst({
where: { isActive: true },
orderBy: { createdAt: "desc" },
@ -60,11 +69,14 @@ export default async function CommissionsPage() {
{commissions.map((commission) => (
<CommissionCard key={commission.id} commission={commission} />
))}
{customCards.map((card) => (
<CommissionCustomCard key={card.id} card={card} />
))}
<CommissionGuidelines />
</div>
<hr />
<h2 className="text-2xl font-semibold">Request a Commission</h2>
<CommissionOrderForm types={commissions} />
<CommissionOrderForm types={commissions} customCards={customCards} />
</div>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import Image from "next/image";
type CustomCardOption = {
id: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
option: { name: string } | null;
};
type CustomCardExtra = {
id: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
extra: { name: string } | null;
};
export type CommissionCustomCardWithItems = {
id: string;
name: string;
description: string | null;
referenceImageUrl: string | null;
isSpecialOffer: boolean;
options: CustomCardOption[];
extras: CustomCardExtra[];
};
export function CommissionCustomCard({
card,
}: {
card: CommissionCustomCardWithItems;
}) {
return (
<div className="flex flex-col h-full">
<Card className="flex flex-col flex-1 relative overflow-hidden border-2 border-primary/50 shadow-sm">
{card.isSpecialOffer ? (
<div className="pointer-events-none absolute right-0 top-0 z-10">
<div className="absolute right-0 top-16 h-7 w-36 origin-top-right translate-x-10 rotate-45 bg-primary text-primary-foreground shadow-md">
<span className="flex h-full w-full items-center justify-center text-xs font-semibold uppercase tracking-wide">
Special
</span>
</div>
</div>
) : null}
<CardHeader className="gap-2">
<CardTitle className="text-xl font-bold">{card.name}</CardTitle>
<p className="text-muted-foreground text-sm">{card.description}</p>
{card.referenceImageUrl ? (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="group relative overflow-hidden rounded-lg border border-border/60 bg-muted/40"
>
<Image
src={card.referenceImageUrl}
alt={`${card.name} reference`}
width={800}
height={600}
sizes="(max-width: 768px) 90vw, 400px"
className="h-auto w-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
/>
<span className="absolute inset-x-0 bottom-0 bg-background/70 px-2 py-1 text-xs text-foreground/80">
Click to enlarge
</span>
</button>
</DialogTrigger>
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
<DialogHeader className="sr-only">
<DialogTitle>{card.name} reference</DialogTitle>
</DialogHeader>
<div className="flex max-h-[85vh] max-w-[85vw] items-center justify-center rounded-xl border-border/60 bg-muted p-2 shadow-2xl">
<Image
src={card.referenceImageUrl}
alt={`${card.name} reference`}
width={1600}
height={1200}
sizes="85vw"
className="h-auto max-h-[85vh] w-auto max-w-[85vw] rounded-lg object-contain"
/>
</div>
</DialogContent>
</Dialog>
) : null}
</CardHeader>
<CardContent className="flex flex-col justify-start gap-4">
<div>
<h4 className="font-semibold">Options</h4>
<ul className="pl-4 list-disc">
{card.options.map((option) => (
<li key={option.id}>
{option.option?.name}:{" "}
{option.price && option.price !== 0
? `${option.price}`
: option.pricePercent
? `+${option.pricePercent}%`
: option.priceRange && option.priceRange !== "00"
? `${option.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
<div>
{card.extras.length > 0 ? (
<h4 className="font-semibold">Extras</h4>
) : null}
<ul className="pl-4 list-disc">
{card.extras.map((extra) => (
<li key={extra.id}>
{extra.extra?.name}:{" "}
{extra.price && extra.price !== 0
? `${extra.price}`
: extra.pricePercent
? `+${extra.pricePercent}%`
: extra.priceRange && extra.priceRange !== "00"
? `${extra.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -14,6 +14,9 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type {
CommissionCustomInput,
CommissionCustomCard,
CommissionCustomCardExtra,
CommissionCustomCardOption,
CommissionExtra,
CommissionOption,
CommissionType,
@ -37,15 +40,40 @@ type CommissionTypeWithRelations = CommissionType & {
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
};
type Props = {
types: CommissionTypeWithRelations[];
type CommissionCustomCardWithRelations = CommissionCustomCard & {
options: (CommissionCustomCardOption & { option: CommissionOption })[];
extras: (CommissionCustomCardExtra & { extra: CommissionExtra })[];
};
export function CommissionOrderForm({ types }: Props) {
type Props = {
types: CommissionTypeWithRelations[];
customCards: CommissionCustomCardWithRelations[];
};
type SelectedOption = {
id: string;
optionId: string;
option: CommissionOption;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
type SelectedExtra = {
id: string;
extraId: string;
extra: CommissionExtra;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
export function CommissionOrderForm({ types, customCards }: Props) {
const form = useForm<z.infer<typeof commissionOrderSchema>>({
resolver: zodResolver(commissionOrderSchema),
defaultValues: {
typeId: "",
customCardId: "",
optionId: "",
extraIds: [],
customerName: "",
@ -59,19 +87,49 @@ export function CommissionOrderForm({ types }: Props) {
const [isSubmitting, setIsSubmitting] = useState(false);
const typeId = useWatch({ control: form.control, name: "typeId" });
const customCardId = useWatch({ control: form.control, name: "customCardId" });
const optionId = useWatch({ control: form.control, name: "optionId" });
const extraIds = useWatch({ control: form.control, name: "extraIds" });
const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
const selectedCustomCard = useMemo(
() => customCards.find((c) => c.id === customCardId),
[customCards, customCardId]
);
const selection = useMemo<{
kind: "type" | "custom";
name: string;
options: SelectedOption[];
extras: SelectedExtra[];
} | null>(() => {
if (selectedCustomCard) {
return {
kind: "custom",
name: selectedCustomCard.name,
options: selectedCustomCard.options,
extras: selectedCustomCard.extras,
};
}
if (selectedType) {
return {
kind: "type",
name: selectedType.name,
options: selectedType.options,
extras: selectedType.extras,
};
}
return null;
}, [selectedCustomCard, selectedType]);
const selectedOption = useMemo(
() => selectedType?.options.find((o) => o.optionId === optionId),
[selectedType, optionId]
() => selection?.options.find((o) => o.optionId === optionId),
[selection, optionId]
);
const selectedExtras = useMemo(
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
[selectedType, extraIds]
() => selection?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
[selection, extraIds]
);
const [minPrice, maxPrice] = useMemo(() => {
@ -84,6 +142,7 @@ export function CommissionOrderForm({ types }: Props) {
try {
const payload = {
typeId: values.typeId || null,
customCardId: values.customCardId || null,
optionId: values.optionId || null,
extraIds: values.extraIds ?? [],
customerName: values.customerName,
@ -100,6 +159,7 @@ export function CommissionOrderForm({ types }: Props) {
form.reset({
typeId: "",
customCardId: "",
optionId: "",
extraIds: [],
customerName: "",
@ -136,7 +196,12 @@ export function CommissionOrderForm({ types }: Props) {
key={type.id}
type="button"
variant={field.value === type.id ? "default" : "outline"}
onClick={() => field.onChange(type.id)}
onClick={() => {
field.onChange(type.id);
form.setValue("customCardId", "");
form.setValue("optionId", "");
form.setValue("extraIds", []);
}}
disabled={isSubmitting}
>
{type.name}
@ -149,7 +214,40 @@ export function CommissionOrderForm({ types }: Props) {
)}
/>
{selectedType && (
{customCards.length > 0 ? (
<FormField
control={form.control}
name="customCardId"
render={({ field }) => (
<FormItem>
<FormLabel>Custom requests / YCH</FormLabel>
<FormControl>
<div className="flex flex-wrap gap-2">
{customCards.map((card) => (
<Button
key={card.id}
type="button"
variant={field.value === card.id ? "default" : "outline"}
onClick={() => {
field.onChange(card.id);
form.setValue("typeId", "");
form.setValue("optionId", "");
form.setValue("extraIds", []);
}}
disabled={isSubmitting}
>
{card.name}
</Button>
))}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
) : null}
{selection && (
<>
<FormField
control={form.control}
@ -159,7 +257,7 @@ export function CommissionOrderForm({ types }: Props) {
<FormLabel>Base Option</FormLabel>
<FormControl>
<div className="space-y-1">
{selectedType.options.map((opt) => (
{selection.options.map((opt) => (
<label key={opt.id} className="flex items-center gap-2">
<input
type="radio"
@ -186,7 +284,7 @@ export function CommissionOrderForm({ types }: Props) {
<FormLabel>Extras</FormLabel>
<FormControl>
<div className="space-y-1">
{selectedType.extras.map((ext) => (
{selection.extras.map((ext) => (
<label key={ext.id} className="flex items-center gap-2">
<input
type="checkbox"

View File

@ -1,7 +1,8 @@
import * as z from "zod/v4"
export const commissionOrderSchema = z.object({
typeId: z.string().min(1, "Please select a type"),
typeId: z.string().optional(),
customCardId: z.string().optional(),
optionId: z.string().min(1, "Please choose a base option"),
extraIds: z.array(z.string()).optional(),
customFields: z.record(z.string(), z.unknown()).optional(),
@ -9,4 +10,23 @@ export const commissionOrderSchema = z.object({
customerEmail: z.email("Invalid email"),
customerSocials: z.string().optional(),
message: z.string().min(5, "Please describe what you want"),
}).superRefine((data, ctx) => {
const hasType = Boolean(data.typeId && data.typeId.length > 0);
const hasCustom = Boolean(data.customCardId && data.customCardId.length > 0);
if (!hasType && !hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Please select a commission type or a custom card",
});
}
if (hasType && hasCustom) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["typeId"],
message: "Choose either a type or a custom card, not both",
});
}
})