From 2015ea6f2e573fe6aceefdd258bfdb29bbbc4134 Mon Sep 17 00:00:00 2001 From: Citali Date: Sun, 1 Feb 2026 16:21:20 +0100 Subject: [PATCH] Add custom commission types --- .../20260201142100_com_7/migration.sql | 65 ++++++ .../20260201144904_com_8/migration.sql | 5 + prisma/schema.prisma | 62 +++++ .../commissions/customCards/deleteCard.ts | 9 + src/actions/commissions/customCards/images.ts | 94 ++++++++ .../commissions/customCards/newCard.ts | 52 +++++ .../commissions/customCards/updateCard.ts | 51 ++++ .../customCards/updateSortOrder.ts | 16 ++ .../commissions/custom-cards/[id]/page.tsx | 43 ++++ .../commissions/custom-cards/new/page.tsx | 20 ++ .../(admin)/commissions/custom-cards/page.tsx | 34 +++ src/app/api/v1/commissions/route.ts | 19 ++ .../customCards/CustomCardImagePicker.tsx | 116 ++++++++++ .../customCards/EditCustomCardForm.tsx | 187 +++++++++++++++ .../customCards/ListCustomCards.tsx | 219 ++++++++++++++++++ .../customCards/NewCustomCardForm.tsx | 147 ++++++++++++ src/components/global/TopNav.tsx | 6 +- src/components/global/nav.ts | 1 + src/schemas/commissionCustomCard.ts | 35 +++ 19 files changed, 1180 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260201142100_com_7/migration.sql create mode 100644 prisma/migrations/20260201144904_com_8/migration.sql create mode 100644 src/actions/commissions/customCards/deleteCard.ts create mode 100644 src/actions/commissions/customCards/images.ts create mode 100644 src/actions/commissions/customCards/newCard.ts create mode 100644 src/actions/commissions/customCards/updateCard.ts create mode 100644 src/actions/commissions/customCards/updateSortOrder.ts create mode 100644 src/app/(admin)/commissions/custom-cards/[id]/page.tsx create mode 100644 src/app/(admin)/commissions/custom-cards/new/page.tsx create mode 100644 src/app/(admin)/commissions/custom-cards/page.tsx create mode 100644 src/components/commissions/customCards/CustomCardImagePicker.tsx create mode 100644 src/components/commissions/customCards/EditCustomCardForm.tsx create mode 100644 src/components/commissions/customCards/ListCustomCards.tsx create mode 100644 src/components/commissions/customCards/NewCustomCardForm.tsx create mode 100644 src/schemas/commissionCustomCard.ts diff --git a/prisma/migrations/20260201142100_com_7/migration.sql b/prisma/migrations/20260201142100_com_7/migration.sql new file mode 100644 index 0000000..0f77092 --- /dev/null +++ b/prisma/migrations/20260201142100_com_7/migration.sql @@ -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; diff --git a/prisma/migrations/20260201144904_com_8/migration.sql b/prisma/migrations/20260201144904_com_8/migration.sql new file mode 100644 index 0000000..4b90172 --- /dev/null +++ b/prisma/migrations/20260201144904_com_8/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c79f4d..e896983 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] diff --git a/src/actions/commissions/customCards/deleteCard.ts b/src/actions/commissions/customCards/deleteCard.ts new file mode 100644 index 0000000..23df7be --- /dev/null +++ b/src/actions/commissions/customCards/deleteCard.ts @@ -0,0 +1,9 @@ +"use server"; + +import { prisma } from "@/lib/prisma"; + +export async function deleteCommissionCustomCard(id: string) { + await prisma.commissionCustomCard.delete({ + where: { id }, + }); +} diff --git a/src/actions/commissions/customCards/images.ts b/src/actions/commissions/customCards/images.ts new file mode 100644 index 0000000..7e6b1c5 --- /dev/null +++ b/src/actions/commissions/customCards/images.ts @@ -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 { + 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, + }) + ); +} diff --git a/src/actions/commissions/customCards/newCard.ts b/src/actions/commissions/customCards/newCard.ts new file mode 100644 index 0000000..a9ecf81 --- /dev/null +++ b/src/actions/commissions/customCards/newCard.ts @@ -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; +} diff --git a/src/actions/commissions/customCards/updateCard.ts b/src/actions/commissions/customCards/updateCard.ts new file mode 100644 index 0000000..fc3334f --- /dev/null +++ b/src/actions/commissions/customCards/updateCard.ts @@ -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; +} diff --git a/src/actions/commissions/customCards/updateSortOrder.ts b/src/actions/commissions/customCards/updateSortOrder.ts new file mode 100644 index 0000000..6d3e752 --- /dev/null +++ b/src/actions/commissions/customCards/updateSortOrder.ts @@ -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 }, + }) + ) + ); +} diff --git a/src/app/(admin)/commissions/custom-cards/[id]/page.tsx b/src/app/(admin)/commissions/custom-cards/[id]/page.tsx new file mode 100644 index 0000000..e43edb3 --- /dev/null +++ b/src/app/(admin)/commissions/custom-cards/[id]/page.tsx @@ -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 ( +
+
+

Edit Custom Commission Card

+
+ +
+ ); +} diff --git a/src/app/(admin)/commissions/custom-cards/new/page.tsx b/src/app/(admin)/commissions/custom-cards/new/page.tsx new file mode 100644 index 0000000..6d67630 --- /dev/null +++ b/src/app/(admin)/commissions/custom-cards/new/page.tsx @@ -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 ( +
+
+

New Custom Commission Card

+
+ +
+ ); +} diff --git a/src/app/(admin)/commissions/custom-cards/page.tsx b/src/app/(admin)/commissions/custom-cards/page.tsx new file mode 100644 index 0000000..85c7160 --- /dev/null +++ b/src/app/(admin)/commissions/custom-cards/page.tsx @@ -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 ( +
+
+

Custom Commission Cards

+ + + Add new Card + +
+ {cards && cards.length > 0 ? ( + + ) : ( +

No custom cards found.

+ )} +
+ ); +} diff --git a/src/app/api/v1/commissions/route.ts b/src/app/api/v1/commissions/route.ts index c5b943c..a2bb19d 100644 --- a/src/app/api/v1/commissions/route.ts +++ b/src/app/api/v1/commissions/route.ts @@ -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, diff --git a/src/components/commissions/customCards/CustomCardImagePicker.tsx b/src/components/commissions/customCards/CustomCardImagePicker.tsx new file mode 100644 index 0000000..04697dd --- /dev/null +++ b/src/components/commissions/customCards/CustomCardImagePicker.tsx @@ -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; + 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 ( + + Reference image + +
+ + {previewUrl ? ( + + ) : ( +

No image selected.

+ )} + +
+ { + const file = e.target.files?.[0]; + if (file) handleUpload(file); + e.currentTarget.value = ""; + }} + /> + +
+
+
+ + Upload and preview a reference image stored in the custom card bucket folder. + +
+ ); +} diff --git a/src/components/commissions/customCards/EditCustomCardForm.tsx b/src/components/commissions/customCards/EditCustomCardForm.tsx new file mode 100644 index 0000000..59aae8a --- /dev/null +++ b/src/components/commissions/customCards/EditCustomCardForm.tsx @@ -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({ + 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 ( +
+
+ + ( + + Name + + + + The name of the custom commission card. + + + )} + /> + ( + + Description + + + + Optional description. + + + )} + /> + + ( + +
+ Visible on app + Controls whether the card is shown. +
+ + + +
+ )} + /> + + ( + +
+ Special offer + Adds a special offer badge on the app. +
+ + + +
+ )} + /> + + } + /> + + + + +
+ + +
+ + +
+ ); +} diff --git a/src/components/commissions/customCards/ListCustomCards.tsx b/src/components/commissions/customCards/ListCustomCards.tsx new file mode 100644 index 0000000..19a3e8a --- /dev/null +++ b/src/components/commissions/customCards/ListCustomCards.tsx @@ -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(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 ( + <> +
+ + i.id)} strategy={rectSortingStrategy}> + {items.map((card) => ( + + + +
+ {card.name} + {!card.isVisible ? ( + Hidden + ) : ( + Visible + )} + {card.isSpecialOffer ? ( + + Special + + ) : null} +
+ {card.description} + {card.referenceImageUrl ? ( +

Has image

+ ) : null} +
+ +
+

Options

+
    + {card.options.map((opt) => ( +
  • + {opt.option?.name}:{" "} + {opt.price !== null + ? `${opt.price}€` + : opt.pricePercent + ? `+${opt.pricePercent}%` + : opt.priceRange + ? `${opt.priceRange}€` + : "Included"} +
  • + ))} +
+
+
+

Extras

+
    + {card.extras.map((ext) => ( +
  • + {ext.extra?.name}:{" "} + {ext.price !== null + ? `${ext.price}€` + : ext.pricePercent + ? `+${ext.pricePercent}%` + : ext.priceRange + ? `${ext.priceRange}€` + : "Included"} +
  • + ))} +
+
+
+ + + + + + +
+
+ ))} +
+
+
+ + + + + Delete this custom card? + +

This action cannot be undone. Are you sure you want to continue?

+ + + + +
+
+ + ); +} diff --git a/src/components/commissions/customCards/NewCustomCardForm.tsx b/src/components/commissions/customCards/NewCustomCardForm.tsx new file mode 100644 index 0000000..20d4b49 --- /dev/null +++ b/src/components/commissions/customCards/NewCustomCardForm.tsx @@ -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({ + 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 ( +
+
+ + ( + + Name + + + + The name of the custom commission card. + + + )} + /> + ( + + Description + + + + Optional description. + + + )} + /> + + ( + +
+ Visible on app + Controls whether the card is shown. +
+ + + +
+ )} + /> + + ( + +
+ Special offer + Adds a special offer badge on the app. +
+ + + +
+ )} + /> + + } + /> + + + + +
+ + +
+ + +
+ ); +} diff --git a/src/components/global/TopNav.tsx b/src/components/global/TopNav.tsx index 1944c18..fddafd0 100644 --- a/src/components/global/TopNav.tsx +++ b/src/components/global/TopNav.tsx @@ -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() { ); -} \ No newline at end of file +} diff --git a/src/components/global/nav.ts b/src/components/global/nav.ts index 92cf756..d88edf8 100644 --- a/src/components/global/nav.ts +++ b/src/components/global/nav.ts @@ -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" }, diff --git a/src/schemas/commissionCustomCard.ts b/src/schemas/commissionCustomCard.ts new file mode 100644 index 0000000..aa2b45d --- /dev/null +++ b/src/schemas/commissionCustomCard.ts @@ -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 '10–80'") + .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 '10–80'") + .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;