Add custom commission types

This commit is contained in:
2026-02-01 16:21:20 +01:00
parent e869f19142
commit 2015ea6f2e
19 changed files with 1180 additions and 1 deletions

View File

@ -0,0 +1,65 @@
-- CreateTable
CREATE TABLE "CommissionCustomCard" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"name" TEXT NOT NULL,
"description" TEXT,
"referenceImageUrl" TEXT,
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"isSpecialOffer" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "CommissionCustomCard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionCustomCardOption" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"cardId" TEXT NOT NULL,
"optionId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionCustomCardOption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CommissionCustomCardExtra" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"sortIndex" INTEGER NOT NULL DEFAULT 0,
"cardId" TEXT NOT NULL,
"extraId" TEXT NOT NULL,
"priceRange" TEXT,
"pricePercent" DOUBLE PRECISION,
"price" DOUBLE PRECISION,
CONSTRAINT "CommissionCustomCardExtra_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommissionCustomCard_isVisible_sortIndex_idx" ON "CommissionCustomCard"("isVisible", "sortIndex");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionCustomCardOption_cardId_optionId_key" ON "CommissionCustomCardOption"("cardId", "optionId");
-- CreateIndex
CREATE UNIQUE INDEX "CommissionCustomCardExtra_cardId_extraId_key" ON "CommissionCustomCardExtra"("cardId", "extraId");
-- AddForeignKey
ALTER TABLE "CommissionCustomCardOption" ADD CONSTRAINT "CommissionCustomCardOption_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "CommissionCustomCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionCustomCardOption" ADD CONSTRAINT "CommissionCustomCardOption_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionCustomCardExtra" ADD CONSTRAINT "CommissionCustomCardExtra_cardId_fkey" FOREIGN KEY ("cardId") REFERENCES "CommissionCustomCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommissionCustomCardExtra" ADD CONSTRAINT "CommissionCustomCardExtra_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "CommissionRequest" ADD COLUMN "customCardId" TEXT;
-- AddForeignKey
ALTER TABLE "CommissionRequest" ADD CONSTRAINT "CommissionRequest_customCardId_fkey" FOREIGN KEY ("customCardId") REFERENCES "CommissionCustomCard"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -271,6 +271,26 @@ model CommissionType {
requests CommissionRequest[]
}
model CommissionCustomCard {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
name String
description String?
referenceImageUrl String?
isVisible Boolean @default(true)
isSpecialOffer Boolean @default(false)
options CommissionCustomCardOption[]
extras CommissionCustomCardExtra[]
requests CommissionRequest[]
@@index([isVisible, sortIndex])
}
model CommissionOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -282,6 +302,7 @@ model CommissionOption {
description String?
types CommissionTypeOption[]
customCards CommissionCustomCardOption[]
requests CommissionRequest[]
}
@ -316,6 +337,7 @@ model CommissionExtra {
requests CommissionRequest[]
types CommissionTypeExtra[]
customCards CommissionCustomCardExtra[]
}
model CommissionTypeExtra {
@ -337,6 +359,25 @@ model CommissionTypeExtra {
@@unique([typeId, extraId])
}
model CommissionCustomCardOption {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
cardId String
optionId String
priceRange String?
pricePercent Float?
price Float?
card CommissionCustomCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
option CommissionOption @relation(fields: [optionId], references: [id])
@@unique([cardId, optionId])
}
model CommissionCustomInput {
id String @id @default(cuid())
createdAt DateTime @default(now())
@ -368,6 +409,25 @@ model CommissionTypeCustomInput {
@@unique([typeId, customInputId])
}
model CommissionCustomCardExtra {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sortIndex Int @default(0)
cardId String
extraId String
priceRange String?
pricePercent Float?
price Float?
card CommissionCustomCard @relation(fields: [cardId], references: [id], onDelete: Cascade)
extra CommissionExtra @relation(fields: [extraId], references: [id])
@@unique([cardId, extraId])
}
model CommissionRequest {
id String @id @default(cuid())
index Int @default(autoincrement())
@ -386,8 +446,10 @@ model CommissionRequest {
optionId String?
typeId String?
customCardId String?
option CommissionOption? @relation(fields: [optionId], references: [id])
type CommissionType? @relation(fields: [typeId], references: [id])
customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id])
extras CommissionExtra[]
files CommissionRequestFile[]

View File

@ -0,0 +1,9 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function deleteCommissionCustomCard(id: string) {
await prisma.commissionCustomCard.delete({
where: { id },
});
}

View File

@ -0,0 +1,94 @@
"use server";
import { s3 } from "@/lib/s3";
import {
DeleteObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
} from "@aws-sdk/client-s3";
const PREFIX = "commissions/custom-cards/";
export type CommissionCustomCardImageItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}
function sanitizeFilename(name: string) {
return name.replace(/[^a-zA-Z0-9._-]/g, "_");
}
export async function listCommissionCustomCardImages(): Promise<
CommissionCustomCardImageItem[]
> {
const command = new ListObjectsV2Command({
Bucket: `${process.env.BUCKET_NAME}`,
Prefix: PREFIX,
});
const res = await s3.send(command);
return (
res.Contents?.filter((obj) => obj.Key && obj.Key !== PREFIX).map((obj) => {
const key = obj.Key as string;
return {
key,
url: buildImageUrl(key),
size: obj.Size ?? null,
lastModified: obj.LastModified?.toISOString() ?? null,
};
}) ?? []
);
}
export async function uploadCommissionCustomCardImage(
formData: FormData
): Promise<CommissionCustomCardImageItem> {
const file = formData.get("file");
if (!(file instanceof File)) {
throw new Error("Missing file");
}
if (!file.type.startsWith("image/")) {
throw new Error("Only image uploads are allowed");
}
const safeName = sanitizeFilename(file.name || "custom-card");
const key = `${PREFIX}${Date.now()}-${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
Body: buffer,
ContentType: file.type,
})
);
return {
key,
url: buildImageUrl(key),
size: file.size,
lastModified: new Date().toISOString(),
};
}
export async function deleteCommissionCustomCardImage(key: string) {
if (!key.startsWith(PREFIX)) {
throw new Error("Invalid key");
}
await s3.send(
new DeleteObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: key,
})
);
}

View File

@ -0,0 +1,52 @@
"use server";
import { prisma } from "@/lib/prisma";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
export async function createCommissionCustomCard(
formData: CommissionCustomCardValues
) {
const parsed = commissionCustomCardSchema.safeParse(formData);
if (!parsed.success) {
console.error("Validation failed", parsed.error);
throw new Error("Invalid input");
}
const data = parsed.data;
const created = await prisma.commissionCustomCard.create({
data: {
name: data.name,
description: data.description,
referenceImageUrl: data.referenceImageUrl ?? null,
isVisible: data.isVisible ?? true,
isSpecialOffer: data.isSpecialOffer ?? false,
options: {
create:
data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price,
pricePercent: opt.pricePercent,
priceRange: opt.priceRange,
sortIndex: index,
})) ?? [],
},
extras: {
create:
data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price,
pricePercent: ext.pricePercent,
priceRange: ext.priceRange,
sortIndex: index,
})) ?? [],
},
},
});
return created;
}

View File

@ -0,0 +1,51 @@
"use server";
import { prisma } from "@/lib/prisma";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
export async function updateCommissionCustomCard(
id: string,
rawData: CommissionCustomCardValues
) {
const data = commissionCustomCardSchema.parse(rawData);
const updated = await prisma.commissionCustomCard.update({
where: { id },
data: {
name: data.name,
description: data.description,
referenceImageUrl: data.referenceImageUrl ?? null,
isVisible: data.isVisible ?? true,
isSpecialOffer: data.isSpecialOffer ?? false,
options: {
deleteMany: {},
create: data.options?.map((opt, index) => ({
option: { connect: { id: opt.optionId } },
price: opt.price ?? null,
pricePercent: opt.pricePercent ?? null,
priceRange: opt.priceRange ?? null,
sortIndex: index,
})),
},
extras: {
deleteMany: {},
create: data.extras?.map((ext, index) => ({
extra: { connect: { id: ext.extraId } },
price: ext.price ?? null,
pricePercent: ext.pricePercent ?? null,
priceRange: ext.priceRange ?? null,
sortIndex: index,
})),
},
},
include: {
options: true,
extras: true,
},
});
return updated;
}

View File

@ -0,0 +1,16 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function updateCommissionCustomCardSortOrder(
items: { id: string; sortIndex: number }[]
) {
await prisma.$transaction(
items.map((item) =>
prisma.commissionCustomCard.update({
where: { id: item.id },
data: { sortIndex: item.sortIndex },
})
)
);
}

View File

@ -0,0 +1,43 @@
import { listCommissionCustomCardImages } from "@/actions/commissions/customCards/images";
import EditCustomCardForm from "@/components/commissions/customCards/EditCustomCardForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
export default async function CommissionCustomCardEditPage({
params,
}: {
params: { id: string };
}) {
const { id } = await params;
const [card, options, extras, images] = await Promise.all([
prisma.commissionCustomCard.findUnique({
where: { id },
include: {
options: { orderBy: { sortIndex: "asc" } },
extras: { orderBy: { sortIndex: "asc" } },
},
}),
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(),
]);
if (!card) {
notFound();
}
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Edit Custom Commission Card</h1>
</div>
<EditCustomCardForm
card={card}
allOptions={options}
allExtras={extras}
images={images}
/>
</div>
);
}

View File

@ -0,0 +1,20 @@
import { listCommissionCustomCardImages } from "@/actions/commissions/customCards/images";
import NewCustomCardForm from "@/components/commissions/customCards/NewCustomCardForm";
import { prisma } from "@/lib/prisma";
export default async function CommissionCustomCardsNewPage() {
const [options, extras, images] = await Promise.all([
prisma.commissionOption.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
prisma.commissionExtra.findMany({ orderBy: [{ sortIndex: "asc" }, { name: "asc" }] }),
listCommissionCustomCardImages(),
]);
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">New Custom Commission Card</h1>
</div>
<NewCustomCardForm options={options} extras={extras} images={images} />
</div>
);
}

View File

@ -0,0 +1,34 @@
import ListCustomCards from "@/components/commissions/customCards/ListCustomCards";
import { prisma } from "@/lib/prisma";
import { PlusCircleIcon } from "lucide-react";
import Link from "next/link";
export default async function CommissionCustomCardsPage() {
const cards = await prisma.commissionCustomCard.findMany({
include: {
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
});
return (
<div>
<div className="flex gap-4 justify-between pb-8">
<h1 className="text-2xl font-bold mb-4">Custom Commission Cards</h1>
<Link
href="/commissions/custom-cards/new"
className="flex gap-2 items-center cursor-pointer bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded"
>
<PlusCircleIcon className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all text-primary-foreground" />
Add new Card
</Link>
</div>
{cards && cards.length > 0 ? (
<ListCustomCards cards={cards} />
) : (
<p className="text-muted-foreground italic">No custom cards found.</p>
)}
</div>
);
}

View File

@ -7,6 +7,7 @@ import { z } from "zod/v4";
const payloadSchema = z.object({
typeId: z.string().min(1).optional().nullable(),
customCardId: z.string().min(1).optional().nullable(),
optionId: z.string().min(1).optional().nullable(),
extraIds: z.array(z.string().min(1)).default([]),
@ -14,6 +15,23 @@ const payloadSchema = z.object({
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
}).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",
});
}
});
function safeJsonParse(input: string) {
@ -83,6 +101,7 @@ export async function POST(request: Request) {
message: payload.data.message,
typeId: payload.data.typeId ?? null,
customCardId: payload.data.customCardId ?? null,
optionId: payload.data.optionId ?? null,
ipAddress,

View File

@ -0,0 +1,116 @@
"use client";
import {
deleteCommissionCustomCardImage,
uploadCommissionCustomCardImage,
} from "@/actions/commissions/customCards/images";
import { Button } from "@/components/ui/button";
import {
FormControl,
FormDescription,
FormItem,
FormLabel,
} from "@/components/ui/form";
import type { CommissionCustomCardValues } from "@/schemas/commissionCustomCard";
import Image from "next/image";
import { useMemo, useTransition } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useWatch } from "react-hook-form";
type Props = {
form: UseFormReturn<CommissionCustomCardValues>;
initialImages: { key: string; url: string }[];
};
export function CustomCardImagePicker({ form }: Props) {
const [isPending, startTransition] = useTransition();
const referenceImageUrl = useWatch({
control: form.control,
name: "referenceImageUrl",
});
const previewUrl = useMemo(() => {
if (!referenceImageUrl) return "";
return referenceImageUrl;
}, [referenceImageUrl]);
const handleUpload = (file: File) => {
const fd = new FormData();
fd.append("file", file);
startTransition(async () => {
const item = await uploadCommissionCustomCardImage(fd);
form.setValue("referenceImageUrl", item.url, { shouldDirty: true });
});
};
const handleDelete = () => {
const url = referenceImageUrl ?? "";
const key = url.replace(/^\/api\/image\//, "");
const decodedKey = decodeURIComponent(key);
if (!decodedKey) return;
if (!window.confirm("Delete this image from S3?")) return;
startTransition(async () => {
await deleteCommissionCustomCardImage(decodedKey);
form.setValue("referenceImageUrl", null, { shouldDirty: true });
});
};
return (
<FormItem>
<FormLabel>Reference image</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
<input type="hidden" {...form.register("referenceImageUrl")} />
{previewUrl ? (
<div className="flex flex-col gap-2">
<div className="relative w-full max-w-md overflow-hidden rounded-lg border border-border/60 bg-muted/40">
<Image
src={previewUrl}
alt="Reference preview"
width={900}
height={600}
className="h-auto w-full object-cover"
/>
</div>
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
>
Open full size
</a>
</div>
) : (
<p className="text-sm text-muted-foreground">No image selected.</p>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.currentTarget.value = "";
}}
/>
<Button
type="button"
variant="secondary"
onClick={handleDelete}
disabled={!referenceImageUrl || isPending}
>
Delete image
</Button>
</div>
</div>
</FormControl>
<FormDescription>
Upload and preview a reference image stored in the custom card bucket folder.
</FormDescription>
</FormItem>
);
}

View File

@ -0,0 +1,187 @@
"use client";
import { updateCommissionCustomCard } from "@/actions/commissions/customCards/updateCard";
import type { CommissionCustomCardImageItem } from "@/actions/commissions/customCards/images";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { CommissionExtra, CommissionOption } from "@/generated/prisma/client";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { CommissionExtraField } from "../types/form/CommissionExtraField";
import { CommissionOptionField } from "../types/form/CommissionOptionField";
import { CustomCardImagePicker } from "./CustomCardImagePicker";
type CustomCardOption = {
optionId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
type CustomCardExtra = {
extraId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
};
type CustomCardWithItems = {
id: string;
name: string;
description: string | null;
referenceImageUrl: string | null;
isVisible: boolean;
isSpecialOffer: boolean;
options: CustomCardOption[];
extras: CustomCardExtra[];
};
type Props = {
card: CustomCardWithItems;
allOptions: CommissionOption[];
allExtras: CommissionExtra[];
images: CommissionCustomCardImageItem[];
};
export default function EditCustomCardForm({
card,
allOptions,
allExtras,
images,
}: Props) {
const router = useRouter();
const form = useForm<CommissionCustomCardValues>({
resolver: zodResolver(commissionCustomCardSchema),
defaultValues: {
name: card.name,
description: card.description ?? "",
isVisible: card.isVisible,
isSpecialOffer: card.isSpecialOffer,
referenceImageUrl: card.referenceImageUrl ?? null,
options: card.options.map((o) => ({
optionId: o.optionId,
price: o.price ?? undefined,
pricePercent: o.pricePercent ?? undefined,
priceRange: o.priceRange ?? undefined,
})),
extras: card.extras.map((e) => ({
extraId: e.extraId,
price: e.price ?? undefined,
pricePercent: e.pricePercent ?? undefined,
priceRange: e.priceRange ?? undefined,
})),
},
});
async function onSubmit(values: CommissionCustomCardValues) {
try {
await updateCommissionCustomCard(card.id, values);
toast.success("Custom commission card updated.");
router.push("/commissions/custom-cards");
} catch (err) {
console.error(err);
toast("Failed to update custom commission card.");
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>The name of the custom commission card.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Optional description.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Visible on app</FormLabel>
<FormDescription>Controls whether the card is shown.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSpecialOffer"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Special offer</FormLabel>
<FormDescription>Adds a special offer badge on the app.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceImageUrl"
render={() => <CustomCardImagePicker form={form} initialImages={images} />}
/>
<CommissionOptionField options={allOptions} />
<CommissionExtraField extras={allExtras} />
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,219 @@
"use client";
import { deleteCommissionCustomCard } from "@/actions/commissions/customCards/deleteCard";
import { updateCommissionCustomCardSortOrder } from "@/actions/commissions/customCards/updateSortOrder";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
closestCenter,
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext } from "@dnd-kit/sortable";
import { PencilIcon, TrashIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState, useTransition } from "react";
import SortableItemCard from "../types/SortableItemCard";
type CustomCardOption = {
id: string;
optionId: string;
price: number | null;
pricePercent: number | null;
priceRange: string | null;
option: { name: string } | null;
};
type CustomCardExtra = {
id: string;
extraId: 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;
isVisible: boolean;
isSpecialOffer: boolean;
options: CustomCardOption[];
extras: CustomCardExtra[];
};
export default function ListCustomCards({
cards,
}: {
cards: CommissionCustomCardWithItems[];
}) {
const [items, setItems] = useState(cards);
const [isMounted, setIsMounted] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
useEffect(() => {
setIsMounted(true);
}, []);
const sensors = useSensors(useSensor(PointerSensor));
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((i) => i.id === active.id);
const newIndex = items.findIndex((i) => i.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
await updateCommissionCustomCardSortOrder(
newItems.map((item, i) => ({ id: item.id, sortIndex: i }))
);
}
};
const confirmDelete = () => {
if (!deleteTargetId) return;
startTransition(async () => {
await deleteCommissionCustomCard(deleteTargetId);
setItems((prev) => prev.filter((i) => i.id !== deleteTargetId));
setDialogOpen(false);
setDeleteTargetId(null);
});
};
if (!isMounted) return null;
return (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((i) => i.id)} strategy={rectSortingStrategy}>
{items.map((card) => (
<SortableItemCard key={card.id} id={card.id}>
<Card>
<CardHeader className="relative">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-xl truncate">{card.name}</CardTitle>
{!card.isVisible ? (
<Badge variant="secondary">Hidden</Badge>
) : (
<Badge variant="outline">Visible</Badge>
)}
{card.isSpecialOffer ? (
<Badge className="bg-amber-500 text-amber-950 hover:bg-amber-500">
Special
</Badge>
) : null}
</div>
<CardDescription>{card.description}</CardDescription>
{card.referenceImageUrl ? (
<p className="text-xs text-muted-foreground">Has image</p>
) : 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((opt) => (
<li key={opt.id}>
{opt.option?.name}:{" "}
{opt.price !== null
? `${opt.price}`
: opt.pricePercent
? `+${opt.pricePercent}%`
: opt.priceRange
? `${opt.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
<div>
<h4 className="font-semibold">Extras</h4>
<ul className="pl-4 list-disc">
{card.extras.map((ext) => (
<li key={ext.id}>
{ext.extra?.name}:{" "}
{ext.price !== null
? `${ext.price}`
: ext.pricePercent
? `+${ext.pricePercent}%`
: ext.priceRange
? `${ext.priceRange}`
: "Included"}
</li>
))}
</ul>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Link href={`/commissions/custom-cards/${card.id}`} className="w-full">
<Button variant="default" className="w-full flex items-center gap-2">
<PencilIcon className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="destructive"
className="w-full flex items-center gap-2"
onClick={() => {
setDeleteTargetId(card.id);
setDialogOpen(true);
}}
>
<TrashIcon className="h-4 w-4" />
Delete
</Button>
</CardFooter>
</Card>
</SortableItemCard>
))}
</SortableContext>
</DndContext>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this custom card?</DialogTitle>
</DialogHeader>
<p>This action cannot be undone. Are you sure you want to continue?</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button variant="destructive" disabled={isPending} onClick={confirmDelete}>
Confirm Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,147 @@
"use client";
import { createCommissionCustomCard } from "@/actions/commissions/customCards/newCard";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import type { CommissionExtra, CommissionOption } from "@/generated/prisma/client";
import {
commissionCustomCardSchema,
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { CommissionExtraField } from "../types/form/CommissionExtraField";
import { CommissionOptionField } from "../types/form/CommissionOptionField";
import { CustomCardImagePicker } from "./CustomCardImagePicker";
import type { CommissionCustomCardImageItem } from "@/actions/commissions/customCards/images";
type Props = {
options: CommissionOption[];
extras: CommissionExtra[];
images: CommissionCustomCardImageItem[];
};
export default function NewCustomCardForm({ options, extras, images }: Props) {
const router = useRouter();
const form = useForm<CommissionCustomCardValues>({
resolver: zodResolver(commissionCustomCardSchema),
defaultValues: {
name: "",
description: "",
isVisible: true,
isSpecialOffer: false,
referenceImageUrl: null,
options: [],
extras: [],
},
});
async function onSubmit(values: CommissionCustomCardValues) {
try {
const created = await createCommissionCustomCard(values);
console.log("Commission custom card created:", created);
toast("Custom commission card created.");
router.push("/commissions/custom-cards");
} catch (err) {
console.error(err);
toast("Failed to create custom commission card.");
}
}
return (
<div className="flex flex-col gap-8">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>The name of the custom commission card.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>Optional description.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isVisible"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Visible on app</FormLabel>
<FormDescription>Controls whether the card is shown.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="isSpecialOffer"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>Special offer</FormLabel>
<FormDescription>Adds a special offer badge on the app.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="referenceImageUrl"
render={() => <CustomCardImagePicker form={form} initialImages={images} />}
/>
<CommissionOptionField options={options} />
<CommissionExtraField extras={extras} />
<div className="flex flex-col gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>
Cancel
</Button>
</div>
</form>
</Form>
</div>
);
}

View File

@ -34,6 +34,10 @@ const commissionItems = [
title: "Types",
href: "/commissions/types",
},
{
title: "Custom Cards",
href: "/commissions/custom-cards",
},
{
title: "Guidelines",
href: "/commissions/guidelines",
@ -203,4 +207,4 @@ export default function TopNav() {
</NavigationMenuList>
</NavigationMenu>
);
}
}

View File

@ -45,6 +45,7 @@ export const adminNav: AdminNavGroup[] = [
{ title: "Requests", href: "/commissions/requests" },
{ title: "Board", href: "/commissions/kanban" },
{ title: "Types", href: "/commissions/types" },
{ title: "Custom Cards", href: "/commissions/custom-cards" },
{ title: "TypeOptions", href: "/commissions/types/options" },
{ title: "TypeExtras", href: "/commissions/types/extras" },
{ title: "Guidelines", href: "/commissions/guidelines" },

View File

@ -0,0 +1,35 @@
import * as z from "zod/v4";
const rangePattern = /^\d{1,3}\d{1,3}$/;
const optionField = z.object({
optionId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z
.string()
.regex(rangePattern, "Format must be like '1080'")
.optional(),
});
const extraField = z.object({
extraId: z.string(),
price: z.number().optional(),
pricePercent: z.number().optional(),
priceRange: z
.string()
.regex(rangePattern, "Format must be like '1080'")
.optional(),
});
export const commissionCustomCardSchema = z.object({
name: z.string().min(1, "Name is required. Min 1 character."),
description: z.string().optional(),
isVisible: z.boolean().default(true),
isSpecialOffer: z.boolean().default(false),
referenceImageUrl: z.string().nullable().optional(),
options: z.array(optionField).optional(),
extras: z.array(extraField).optional(),
});
export type CommissionCustomCardValues = z.infer<typeof commissionCustomCardSchema>;