From 648ecda8d4e8e3bd69f0c41b8ec359f2aec2391f Mon Sep 17 00:00:00 2001 From: Citali Date: Sat, 5 Jul 2025 23:31:32 +0200 Subject: [PATCH] Add CommissionTypeForm --- package-lock.json | 130 ++++++++++ package.json | 6 + .../migration.sql | 127 ++++++++++ .../migration.sql | 37 +++ .../migration.sql | 5 + prisma/schema.prisma | 230 ++++++++++++------ .../items/commissions/types/newType.ts | 58 +++++ src/app/items/commissions/types/new/page.tsx | 21 ++ src/app/items/commissions/types/page.tsx | 26 ++ src/app/layout.tsx | 2 + .../items/commissions/types/ListTypes.tsx | 64 +++++ .../items/commissions/types/NewTypeForm.tsx | 89 +++++++ .../types/form/ComboboxCreateable.tsx | 111 +++++++++ .../types/form/CommissionExtraField.tsx | 159 ++++++++++++ .../types/form/CommissionOptionField.tsx | 160 ++++++++++++ src/components/ui/command.tsx | 184 ++++++++++++++ src/components/ui/dual-range.tsx | 39 +++ src/components/ui/popover.tsx | 48 ++++ src/components/ui/sonner.tsx | 25 ++ src/lib/prisma.ts | 14 ++ src/schemas/commissionType.ts | 26 ++ 21 files changed, 1485 insertions(+), 76 deletions(-) create mode 100644 prisma/migrations/20250705193450_commision_types/migration.sql create mode 100644 prisma/migrations/20250705194630_commision_types/migration.sql create mode 100644 prisma/migrations/20250705212806_commision_types/migration.sql create mode 100644 src/actions/items/commissions/types/newType.ts create mode 100644 src/app/items/commissions/types/new/page.tsx create mode 100644 src/app/items/commissions/types/page.tsx create mode 100644 src/components/items/commissions/types/ListTypes.tsx create mode 100644 src/components/items/commissions/types/NewTypeForm.tsx create mode 100644 src/components/items/commissions/types/form/ComboboxCreateable.tsx create mode 100644 src/components/items/commissions/types/form/CommissionExtraField.tsx create mode 100644 src/components/items/commissions/types/form/CommissionOptionField.tsx create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dual-range.tsx create mode 100644 src/components/ui/popover.tsx create mode 100644 src/components/ui/sonner.tsx create mode 100644 src/lib/prisma.ts create mode 100644 src/schemas/commissionType.ts diff --git a/package-lock.json b/package-lock.json index 18cf248..f5d2395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", "@prisma/client": "^6.11.1", @@ -17,12 +19,15 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.525.0", "next": "15.3.5", "next-auth": "^5.0.0-beta.29", @@ -30,6 +35,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.59.0", + "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "zod": "^3.25.73" }, @@ -132,6 +138,34 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/utilities": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", @@ -1589,6 +1623,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -1790,6 +1861,39 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -3405,6 +3509,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -6876,6 +6996,16 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sonner": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.6.tgz", + "integrity": "sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index a57486b..278a414 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ }, "dependencies": { "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.1.1", "@prisma/client": "^6.11.1", @@ -18,12 +20,15 @@ "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.525.0", "next": "15.3.5", "next-auth": "^5.0.0-beta.29", @@ -31,6 +36,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.59.0", + "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", "zod": "^3.25.73" }, diff --git a/prisma/migrations/20250705193450_commision_types/migration.sql b/prisma/migrations/20250705193450_commision_types/migration.sql new file mode 100644 index 0000000..633aeb0 --- /dev/null +++ b/prisma/migrations/20250705193450_commision_types/migration.sql @@ -0,0 +1,127 @@ +/* + Warnings: + + - You are about to drop the column `active` on the `CommissionType` table. All the data in the column will be lost. + - You are about to drop the column `basePrice` on the `CommissionType` table. All the data in the column will be lost. + - You are about to drop the column `deliveryEst` on the `CommissionType` table. All the data in the column will be lost. + - You are about to drop the column `tags` on the `CommissionType` table. All the data in the column will be lost. + - You are about to drop the column `title` on the `CommissionType` table. All the data in the column will be lost. + - You are about to drop the `Artwork` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `CommissionRequest` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `Preferences` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `PresentationGroup` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `TOS` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `User` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `name` to the `CommissionType` table without a default value. This is not possible if the table is not empty. + - Added the required column `updatedAt` to the `CommissionType` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Artwork" DROP CONSTRAINT "Artwork_groupId_fkey"; + +-- DropForeignKey +ALTER TABLE "CommissionRequest" DROP CONSTRAINT "CommissionRequest_typeId_fkey"; + +-- AlterTable +ALTER TABLE "CommissionType" DROP COLUMN "active", +DROP COLUMN "basePrice", +DROP COLUMN "deliveryEst", +DROP COLUMN "tags", +DROP COLUMN "title", +ADD COLUMN "name" TEXT NOT NULL, +ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; + +-- DropTable +DROP TABLE "Artwork"; + +-- DropTable +DROP TABLE "CommissionRequest"; + +-- DropTable +DROP TABLE "Preferences"; + +-- DropTable +DROP TABLE "PresentationGroup"; + +-- DropTable +DROP TABLE "TOS"; + +-- DropTable +DROP TABLE "User"; + +-- DropEnum +DROP TYPE "RequestStatus"; + +-- DropEnum +DROP TYPE "Role"; + +-- CreateTable +CREATE TABLE "CommissionOption" ( + "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, + + CONSTRAINT "CommissionOption_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommissionExtra" ( + "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, + + CONSTRAINT "CommissionExtra_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommissionTypeOption" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "typeId" TEXT NOT NULL, + "optionId" TEXT NOT NULL, + "priceRange" TEXT, + "pricePercent" DOUBLE PRECISION, + "price" DOUBLE PRECISION, + + CONSTRAINT "CommissionTypeOption_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommissionExtraOption" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "typeId" TEXT NOT NULL, + "extraId" TEXT NOT NULL, + "priceRange" TEXT, + "pricePercent" DOUBLE PRECISION, + "price" DOUBLE PRECISION, + + CONSTRAINT "CommissionExtraOption_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommissionTypeOption_typeId_optionId_key" ON "CommissionTypeOption"("typeId", "optionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommissionExtraOption_typeId_extraId_key" ON "CommissionExtraOption"("typeId", "extraId"); + +-- AddForeignKey +ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionTypeOption" ADD CONSTRAINT "CommissionTypeOption_optionId_fkey" FOREIGN KEY ("optionId") REFERENCES "CommissionOption"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionExtraOption" ADD CONSTRAINT "CommissionExtraOption_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionExtraOption" ADD CONSTRAINT "CommissionExtraOption_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250705194630_commision_types/migration.sql b/prisma/migrations/20250705194630_commision_types/migration.sql new file mode 100644 index 0000000..97f2094 --- /dev/null +++ b/prisma/migrations/20250705194630_commision_types/migration.sql @@ -0,0 +1,37 @@ +/* + Warnings: + + - You are about to drop the `CommissionExtraOption` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "CommissionExtraOption" DROP CONSTRAINT "CommissionExtraOption_extraId_fkey"; + +-- DropForeignKey +ALTER TABLE "CommissionExtraOption" DROP CONSTRAINT "CommissionExtraOption_typeId_fkey"; + +-- DropTable +DROP TABLE "CommissionExtraOption"; + +-- CreateTable +CREATE TABLE "CommissionTypeExtra" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "typeId" TEXT NOT NULL, + "extraId" TEXT NOT NULL, + "priceRange" TEXT, + "pricePercent" DOUBLE PRECISION, + "price" DOUBLE PRECISION, + + CONSTRAINT "CommissionTypeExtra_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CommissionTypeExtra_typeId_extraId_key" ON "CommissionTypeExtra"("typeId", "extraId"); + +-- AddForeignKey +ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "CommissionType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionTypeExtra" ADD CONSTRAINT "CommissionTypeExtra_extraId_fkey" FOREIGN KEY ("extraId") REFERENCES "CommissionExtra"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250705212806_commision_types/migration.sql b/prisma/migrations/20250705212806_commision_types/migration.sql new file mode 100644 index 0000000..1a8eb31 --- /dev/null +++ b/prisma/migrations/20250705212806_commision_types/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "CommissionTypeExtra" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "CommissionTypeOption" ADD COLUMN "sortIndex" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6c1e4e1..3c35534 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,83 +14,161 @@ datasource db { url = env("DATABASE_URL") } -model User { - id String @id @default(cuid()) - email String @unique - name String? - role Role @default(ADMIN) - createdAt DateTime @default(now()) -} - -enum Role { - ADMIN - ARTIST -} - model CommissionType { - id String @id @default(cuid()) - title String - description String? - basePrice Float - deliveryEst String? // e.g. "2 weeks" - tags String[] // e.g. shaded, sketch, full-body - active Boolean @default(true) - createdAt DateTime @default(now()) - CommissionRequest CommissionRequest[] -} - -model CommissionRequest { - id String @id @default(cuid()) - name String - email String - message String - typeId String - status RequestStatus @default(PENDING) - createdAt DateTime @default(now()) - - type CommissionType @relation(fields: [typeId], references: [id]) -} - -enum RequestStatus { - PENDING - ACCEPTED - IN_PROGRESS - DONE - REJECTED -} - -model Artwork { - id String @id @default(cuid()) - title String - imageUrl String - description String? - tags String[] - formats String[] - isPublic Boolean @default(true) - groupId String? - createdAt DateTime @default(now()) - - group PresentationGroup? @relation(fields: [groupId], references: [id]) -} - -model PresentationGroup { - id String @id @default(cuid()) - name String - description String? - createdAt DateTime @default(now()) - Artwork Artwork[] -} - -model Preferences { - id String @id @default(cuid()) - commissionOpen Boolean @default(true) - defaultDelivery String? // e.g. "7 days" - autoReplyMessage String? - notifyByEmail Boolean @default(true) -} - -model TOS { id String @id @default(cuid()) - content String // Markdown or rich text createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sortIndex Int @default(0) + + name String + + description String? + + options CommissionTypeOption[] + extras CommissionTypeExtra[] } + +model CommissionOption { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sortIndex Int @default(0) + + name String + + description String? + + types CommissionTypeOption[] +} + +model CommissionExtra { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sortIndex Int @default(0) + + name String + + description String? + + types CommissionTypeExtra[] +} + +model CommissionTypeOption { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sortIndex Int @default(0) + + typeId String + optionId String + + priceRange String? + pricePercent Float? + price Float? + + type CommissionType @relation(fields: [typeId], references: [id]) + option CommissionOption @relation(fields: [optionId], references: [id]) + + @@unique([typeId, optionId]) +} + +model CommissionTypeExtra { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + sortIndex Int @default(0) + + typeId String + extraId String + + priceRange String? + pricePercent Float? + price Float? + + type CommissionType @relation(fields: [typeId], references: [id]) + extra CommissionExtra @relation(fields: [extraId], references: [id]) + + @@unique([typeId, extraId]) +} + +// model User { +// id String @id @default(cuid()) +// email String @unique +// name String? +// role Role @default(ADMIN) +// createdAt DateTime @default(now()) +// } + +// enum Role { +// ADMIN +// ARTIST +// } + +// model CommissionType { +// id String @id @default(cuid()) +// title String +// description String? +// basePrice Float +// deliveryEst String? // e.g. "2 weeks" +// tags String[] // e.g. shaded, sketch, full-body +// active Boolean @default(true) +// createdAt DateTime @default(now()) +// CommissionRequest CommissionRequest[] +// } + +// model CommissionRequest { +// id String @id @default(cuid()) +// name String +// email String +// message String +// typeId String +// status RequestStatus @default(PENDING) +// createdAt DateTime @default(now()) + +// type CommissionType @relation(fields: [typeId], references: [id]) +// } + +// enum RequestStatus { +// PENDING +// ACCEPTED +// IN_PROGRESS +// DONE +// REJECTED +// } + +// model Artwork { +// id String @id @default(cuid()) +// title String +// imageUrl String +// description String? +// tags String[] +// formats String[] +// isPublic Boolean @default(true) +// groupId String? +// createdAt DateTime @default(now()) + +// group PresentationGroup? @relation(fields: [groupId], references: [id]) +// } + +// model PresentationGroup { +// id String @id @default(cuid()) +// name String +// description String? +// createdAt DateTime @default(now()) +// Artwork Artwork[] +// } + +// model Preferences { +// id String @id @default(cuid()) +// commissionOpen Boolean @default(true) +// defaultDelivery String? // e.g. "7 days" +// autoReplyMessage String? +// notifyByEmail Boolean @default(true) +// } + +// model TOS { +// id String @id @default(cuid()) +// content String // Markdown or rich text +// createdAt DateTime @default(now()) +// } diff --git a/src/actions/items/commissions/types/newType.ts b/src/actions/items/commissions/types/newType.ts new file mode 100644 index 0000000..5a399a7 --- /dev/null +++ b/src/actions/items/commissions/types/newType.ts @@ -0,0 +1,58 @@ +"use server" + +import prisma from "@/lib/prisma" +import { commissionTypeSchema } from "@/schemas/commissionType" + +export async function createCommissionOption(data: { name: string }) { + return await prisma.commissionOption.create({ + data: { + name: data.name, + description: "", + }, + }) +} + +export async function createCommissionExtra(data: { name: string }) { + return await prisma.commissionExtra.create({ + data: { + name: data.name, + description: "", + }, + }) +} + +export async function createCommissionType(formData: commissionTypeSchema) { + const parsed = commissionTypeSchema.safeParse(formData) + + if (!parsed.success) { + console.error("Validation failed", parsed.error) + throw new Error("Invalid input") + } + + const data = parsed.data + + const created = await prisma.commissionType.create({ + data: { + name: data.name, + description: data.description, + options: { + create: data.options?.map((opt) => ({ + option: { connect: { id: opt.optionId } }, + price: opt.price, + pricePercent: opt.pricePercent, + priceRange: opt.priceRange, + })) || [], + }, + extras: { + create: data.extras?.map((ext) => ({ + extra: { connect: { id: ext.extraId } }, + price: ext.price, + pricePercent: ext.pricePercent, + priceRange: ext.priceRange, + })) || [], + }, + }, + }) + + return created +} \ No newline at end of file diff --git a/src/app/items/commissions/types/new/page.tsx b/src/app/items/commissions/types/new/page.tsx new file mode 100644 index 0000000..9c50035 --- /dev/null +++ b/src/app/items/commissions/types/new/page.tsx @@ -0,0 +1,21 @@ +import NewTypeForm from "@/components/items/commissions/types/NewTypeForm"; +import prisma from "@/lib/prisma"; + +export default async function CommissionTypesNewPage() { + const options = await prisma.commissionOption.findMany({ + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }); + const extras = await prisma.commissionExtra.findMany({ + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }) + + + return ( +
+
+

New Commission Type

+
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/items/commissions/types/page.tsx b/src/app/items/commissions/types/page.tsx new file mode 100644 index 0000000..e518f69 --- /dev/null +++ b/src/app/items/commissions/types/page.tsx @@ -0,0 +1,26 @@ +import ListTypes from "@/components/items/commissions/types/ListTypes"; +import prisma from "@/lib/prisma"; +import { PlusCircleIcon } from "lucide-react"; +import Link from "next/link"; + +export default async function CommissionTypesPage() { + const types = await prisma.commissionType.findMany({ + include: { + options: { include: { option: true } }, + extras: { include: { extra: true } }, + }, + orderBy: [{ sortIndex: "asc" }, { name: "asc" }], + }); + + return ( +
+
+

Commission Types

+ + Add new Type + +
+ {types && types.length > 0 ? :

No types found.

} +
+ ); +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c2542f0..2ccd504 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import Footer from "@/components/global/Footer"; import Header from "@/components/global/Header"; import { ThemeProvider } from "@/components/global/ThemeProvider"; +import { Toaster } from "@/components/ui/sonner"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; @@ -65,6 +66,7 @@ export default function RootLayout({ + diff --git a/src/components/items/commissions/types/ListTypes.tsx b/src/components/items/commissions/types/ListTypes.tsx new file mode 100644 index 0000000..9066c80 --- /dev/null +++ b/src/components/items/commissions/types/ListTypes.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma"; + +type CommissionTypeWithItems = CommissionType & { + options: (CommissionTypeOption & { + option: CommissionOption | null + })[] + extras: (CommissionTypeExtra & { + extra: CommissionExtra | null + })[] +} + +export default function ListTypes({ types }: { types: CommissionTypeWithItems[] }) { + return ( +
+ {types.map(type => ( + + + {type.name} + {type.description} + + +
+

Options

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

Extras

+
    + {type.extras.map((ext) => ( +
  • + {ext.extra?.name}:{" "} + {ext.price !== null + ? `${ext.price}€` + : ext.pricePercent + ? `+${ext.pricePercent}%` + : ext.priceRange + ? `${ext.priceRange}€` + : "Included"} +
  • + ))} +
+
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/components/items/commissions/types/NewTypeForm.tsx b/src/components/items/commissions/types/NewTypeForm.tsx new file mode 100644 index 0000000..14d202d --- /dev/null +++ b/src/components/items/commissions/types/NewTypeForm.tsx @@ -0,0 +1,89 @@ +"use client" + +import { createCommissionType } from "@/actions/items/commissions/types/newType"; +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 { CommissionExtra, CommissionOption } from "@/generated/prisma"; +import { commissionTypeSchema } from "@/schemas/commissionType"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod/v4"; +import { CommissionExtraField } from "./form/CommissionExtraField"; +import { CommissionOptionField } from "./form/CommissionOptionField"; + +type Props = { + options: CommissionOption[], + extras: CommissionExtra[], +} + +export default function NewTypeForm({ options, extras }: Props) { + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(commissionTypeSchema), + defaultValues: { + name: "", + description: "", + options: [], + extras: [], + }, + }) + + async function onSubmit(values: z.infer) { + try { + const created = await createCommissionType(values) + console.log("CommissionType created:", created) + router.push("/items/commissions/types") + } catch (err) { + console.error(err) + toast("Failed to create commission type.") + } + } + + return ( +
+
+ + ( + + Name + + + + The name of the commission type. + + + )} + /> + ( + + Description + + + + Optional description. + + + )} + /> + + + + +
+ + +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/items/commissions/types/form/ComboboxCreateable.tsx b/src/components/items/commissions/types/form/ComboboxCreateable.tsx new file mode 100644 index 0000000..8aee24e --- /dev/null +++ b/src/components/items/commissions/types/form/ComboboxCreateable.tsx @@ -0,0 +1,111 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { Check, ChevronsUpDown, PlusCircle } from "lucide-react" +import { useState } from "react" + +type Option = { + label: string + value: string +} + +type Props = { + options: Option[] + selected: string | undefined + onSelect: (value: string) => void + onCreateNew: (name: string) => void | Promise + placeholder?: string + disabled?: boolean +} + +export function ComboboxCreateable({ + options, + selected, + onSelect, + onCreateNew, + placeholder = "Select or create…", + disabled = false, +}: Props) { + const [open, setOpen] = useState(false) + const [input, setInput] = useState("") + + const selectedOption = options.find((o) => o.value === selected) + + const filteredOptions = input + ? options.filter((opt) => + opt.label.toLowerCase().includes(input.toLowerCase()) + ) + : options + + const showCreate = input && !options.some((o) => o.label.toLowerCase() === input.toLowerCase()) + + return ( + + + + + + + + No results + + {filteredOptions.map((opt) => ( + { + onSelect(opt.value) + setOpen(false) + }} + > + + {opt.label} + + ))} + {showCreate && ( + { + await onCreateNew(input) + setOpen(false) + }} + className="text-primary" + > + + Create “{input}” + + )} + + + + + ) +} diff --git a/src/components/items/commissions/types/form/CommissionExtraField.tsx b/src/components/items/commissions/types/form/CommissionExtraField.tsx new file mode 100644 index 0000000..4a6559e --- /dev/null +++ b/src/components/items/commissions/types/form/CommissionExtraField.tsx @@ -0,0 +1,159 @@ +"use client" + +import { createCommissionExtra } from "@/actions/items/commissions/types/newType" +import { Button } from "@/components/ui/button" +import { DualRangeSlider } from "@/components/ui/dual-range" +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { CommissionExtra } from "@/generated/prisma" +import { useState } from "react" +import { useFieldArray, useFormContext } from "react-hook-form" +import { ComboboxCreateable } from "./ComboboxCreateable" + +type Props = { + extras: CommissionExtra[] +} + +export function CommissionExtraField({ extras: initialExtras }: Props) { + const { control } = useFormContext() + const { fields, append, remove } = useFieldArray({ + control, + name: "extras", + }) + + const [extras, setExtras] = useState(initialExtras) + + return ( +
+

Extras

+ + {fields.map((field, index) => ( +
+ {/* Extra Picker (combobox with create) */} + ( + + Extra + + ({ + label: e.name, + value: e.id, + }))} + selected={extraField.value} + onSelect={extraField.onChange} + onCreateNew={async (name) => { + const newExtra = await createCommissionExtra({ name }) + setExtras((prev) => [...prev, newExtra]) + extraField.onChange(newExtra.id) + }} + /> + + + )} + /> + + {/* Price */} + ( + + Price (€) + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> + + {/* Price Percent */} + ( + + + % + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> + + {/* Range Slider */} + { + const [start, end] = + typeof field.value === "string" && field.value.includes("–") + ? field.value.split("–").map(Number) + : [0, 0] + + return ( + + Range + + + field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) + } + /> + + + ) + }} + /> + + {/* Remove Button */} + +
+ ))} + + +
+ ) +} diff --git a/src/components/items/commissions/types/form/CommissionOptionField.tsx b/src/components/items/commissions/types/form/CommissionOptionField.tsx new file mode 100644 index 0000000..50f9746 --- /dev/null +++ b/src/components/items/commissions/types/form/CommissionOptionField.tsx @@ -0,0 +1,160 @@ +"use client" + +import { createCommissionOption } from "@/actions/items/commissions/types/newType" +import { Button } from "@/components/ui/button" +import { DualRangeSlider } from "@/components/ui/dual-range" +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { CommissionOption } from "@/generated/prisma" +import { useState } from "react" +import { useFieldArray, useFormContext } from "react-hook-form" +import { ComboboxCreateable } from "./ComboboxCreateable" + +type Props = { + options: CommissionOption[] +} + +export function CommissionOptionField({ options: initialOptions }: Props) { + const { control } = useFormContext() + const { fields, append, remove } = useFieldArray({ + control, + name: "options", + }) + + const [options, setOptions] = useState(initialOptions) + + return ( +
+

Options

+ + {fields.map((field, index) => ( +
+ {/* Option Picker (combobox with create) */} + ( + + Option + + ({ + label: o.name, + value: o.id, + }))} + selected={optionField.value} + onSelect={optionField.onChange} + onCreateNew={async (name) => { + const newOption = await createCommissionOption({ name }) + setOptions((prev) => [...prev, newOption]) + optionField.onChange(newOption.id) + }} + /> + + + )} + /> + + {/* Price */} + ( + + Price (€) + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> + + {/* Price Percent */} + ( + + + % + + + field.onChange(e.target.value === "" ? undefined : e.target.valueAsNumber) + } + /> + + + )} + /> + + {/* Price Range Slider */} + { + const [start, end] = + typeof field.value === "string" && field.value.includes("–") + ? field.value.split("–").map(Number) + : [0, 0] + + return ( + + Range + + + field.onChange(`${Math.min(min, max)}–${Math.max(min, max)}`) + } + /> + + + ) + }} + /> + + {/* Remove Button */} + +
+ ))} + + {/* Add Button */} + +
+ ) +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 0000000..8cb4ca7 --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/src/components/ui/dual-range.tsx b/src/components/ui/dual-range.tsx new file mode 100644 index 0000000..8908c08 --- /dev/null +++ b/src/components/ui/dual-range.tsx @@ -0,0 +1,39 @@ +"use client"; + +import * as SliderPrimitive from "@radix-ui/react-slider"; + +type DualRangeProps = { + value: [number, number]; + onChange: (val: [number, number]) => void; + min?: number; + max?: number; + step?: number; +}; + +export function DualRangeSlider({ + value, + onChange, + min = 0, + max = 200, + step = 1, +}: DualRangeProps) { + return ( +
+
Range: {value[0]}–{value[1]}
+ onChange([val[0], val[1]])} + > + + + + + + +
+ ); +} diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..01e468b --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,48 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..7db18c7 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,14 @@ +// import { PrismaClient } from '@/types/prisma' +// import { withAccelerate } from '@prisma/extension-accelerate' + +import { PrismaClient } from "@/generated/prisma" + +const globalForPrisma = global as unknown as { + prisma: PrismaClient +} + +const prisma = globalForPrisma.prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma + +export default prisma \ No newline at end of file diff --git a/src/schemas/commissionType.ts b/src/schemas/commissionType.ts new file mode 100644 index 0000000..97dcabc --- /dev/null +++ b/src/schemas/commissionType.ts @@ -0,0 +1,26 @@ +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 commissionTypeSchema = z.object({ + name: z.string().min(1, "Name is required. Min 1 character."), + description: z.string().optional(), + options: z.array(optionField).optional(), + extras: z.array(extraField).optional(), +}) + +export type commissionTypeSchema = z.infer \ No newline at end of file