Add custom YCH typs for commission page
This commit is contained in:
@ -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>;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
140
src/components/commissions/CommissionCustomCard.tsx
Normal file
140
src/components/commissions/CommissionCustomCard.tsx
Normal 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 !== "0–0"
|
||||
? `${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 !== "0–0"
|
||||
? `${extra.priceRange}€`
|
||||
: "Included"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user