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,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" },