Compare commits
1 Commits
ai-alt-tex
...
ych
| Author | SHA1 | Date | |
|---|---|---|---|
|
2015ea6f2e
|
65
prisma/migrations/20260201142100_com_7/migration.sql
Normal file
65
prisma/migrations/20260201142100_com_7/migration.sql
Normal 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;
|
||||||
5
prisma/migrations/20260201144904_com_8/migration.sql
Normal file
5
prisma/migrations/20260201144904_com_8/migration.sql
Normal 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;
|
||||||
@ -271,6 +271,26 @@ model CommissionType {
|
|||||||
requests CommissionRequest[]
|
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 {
|
model CommissionOption {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -282,6 +302,7 @@ model CommissionOption {
|
|||||||
description String?
|
description String?
|
||||||
|
|
||||||
types CommissionTypeOption[]
|
types CommissionTypeOption[]
|
||||||
|
customCards CommissionCustomCardOption[]
|
||||||
requests CommissionRequest[]
|
requests CommissionRequest[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,6 +337,7 @@ model CommissionExtra {
|
|||||||
|
|
||||||
requests CommissionRequest[]
|
requests CommissionRequest[]
|
||||||
types CommissionTypeExtra[]
|
types CommissionTypeExtra[]
|
||||||
|
customCards CommissionCustomCardExtra[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model CommissionTypeExtra {
|
model CommissionTypeExtra {
|
||||||
@ -337,6 +359,25 @@ model CommissionTypeExtra {
|
|||||||
@@unique([typeId, extraId])
|
@@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 {
|
model CommissionCustomInput {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -368,6 +409,25 @@ model CommissionTypeCustomInput {
|
|||||||
@@unique([typeId, customInputId])
|
@@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 {
|
model CommissionRequest {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
index Int @default(autoincrement())
|
index Int @default(autoincrement())
|
||||||
@ -386,8 +446,10 @@ model CommissionRequest {
|
|||||||
|
|
||||||
optionId String?
|
optionId String?
|
||||||
typeId String?
|
typeId String?
|
||||||
|
customCardId String?
|
||||||
option CommissionOption? @relation(fields: [optionId], references: [id])
|
option CommissionOption? @relation(fields: [optionId], references: [id])
|
||||||
type CommissionType? @relation(fields: [typeId], references: [id])
|
type CommissionType? @relation(fields: [typeId], references: [id])
|
||||||
|
customCard CommissionCustomCard? @relation(fields: [customCardId], references: [id])
|
||||||
|
|
||||||
extras CommissionExtra[]
|
extras CommissionExtra[]
|
||||||
files CommissionRequestFile[]
|
files CommissionRequestFile[]
|
||||||
|
|||||||
@ -1,86 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { getImageBufferFromS3Key } from "@/utils/getImageBufferFromS3";
|
|
||||||
|
|
||||||
export async function generateAltTextForArtwork(
|
|
||||||
artworkId: string,
|
|
||||||
prompt?: string,
|
|
||||||
) {
|
|
||||||
const serviceUrl = process.env.ALT_TEXT_SERVICE_URL;
|
|
||||||
if (!serviceUrl) {
|
|
||||||
throw new Error("ALT_TEXT_SERVICE_URL is not set");
|
|
||||||
}
|
|
||||||
|
|
||||||
const artwork = await prisma.artwork.findUnique({
|
|
||||||
where: { id: artworkId },
|
|
||||||
select: {
|
|
||||||
variants: {
|
|
||||||
where: { type: "original" },
|
|
||||||
select: { s3Key: true },
|
|
||||||
take: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const original = artwork?.variants?.[0];
|
|
||||||
if (!original?.s3Key) {
|
|
||||||
throw new Error("Original image variant not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const buffer = await getImageBufferFromS3Key(original.s3Key);
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
formData.append(
|
|
||||||
"image",
|
|
||||||
new Blob([bytes], { type: "image/jpeg" }),
|
|
||||||
"artwork.jpg",
|
|
||||||
);
|
|
||||||
if (prompt && prompt.trim().length > 0) {
|
|
||||||
formData.append("prompt", prompt.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 120000);
|
|
||||||
|
|
||||||
let response: Response | null = null;
|
|
||||||
let lastError: unknown = null;
|
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
||||||
try {
|
|
||||||
response = await fetch(`${serviceUrl}/caption`, {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
if (attempt === 0) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response) {
|
|
||||||
throw new Error(`Alt text service failed: ${String(lastError)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`Alt text service failed: ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { altText?: string; error?: string };
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(`Alt text service error: ${data.error}`);
|
|
||||||
}
|
|
||||||
if (!data.altText) {
|
|
||||||
throw new Error(`Alt text service returned no result: ${JSON.stringify(data)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.altText.trim();
|
|
||||||
}
|
|
||||||
9
src/actions/commissions/customCards/deleteCard.ts
Normal file
9
src/actions/commissions/customCards/deleteCard.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function deleteCommissionCustomCard(id: string) {
|
||||||
|
await prisma.commissionCustomCard.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
||||||
94
src/actions/commissions/customCards/images.ts
Normal file
94
src/actions/commissions/customCards/images.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/actions/commissions/customCards/newCard.ts
Normal file
52
src/actions/commissions/customCards/newCard.ts
Normal 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;
|
||||||
|
}
|
||||||
51
src/actions/commissions/customCards/updateCard.ts
Normal file
51
src/actions/commissions/customCards/updateCard.ts
Normal 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;
|
||||||
|
}
|
||||||
16
src/actions/commissions/customCards/updateSortOrder.ts
Normal file
16
src/actions/commissions/customCards/updateSortOrder.ts
Normal 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 },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/app/(admin)/commissions/custom-cards/[id]/page.tsx
Normal file
43
src/app/(admin)/commissions/custom-cards/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/(admin)/commissions/custom-cards/new/page.tsx
Normal file
20
src/app/(admin)/commissions/custom-cards/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/app/(admin)/commissions/custom-cards/page.tsx
Normal file
34
src/app/(admin)/commissions/custom-cards/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
const payloadSchema = z.object({
|
const payloadSchema = z.object({
|
||||||
typeId: z.string().min(1).optional().nullable(),
|
typeId: z.string().min(1).optional().nullable(),
|
||||||
|
customCardId: z.string().min(1).optional().nullable(),
|
||||||
optionId: z.string().min(1).optional().nullable(),
|
optionId: z.string().min(1).optional().nullable(),
|
||||||
extraIds: z.array(z.string().min(1)).default([]),
|
extraIds: z.array(z.string().min(1)).default([]),
|
||||||
|
|
||||||
@ -14,6 +15,23 @@ const payloadSchema = z.object({
|
|||||||
customerEmail: z.string().email().max(320),
|
customerEmail: z.string().email().max(320),
|
||||||
customerSocials: z.string().max(2000).optional().nullable(),
|
customerSocials: z.string().max(2000).optional().nullable(),
|
||||||
message: z.string().min(1).max(20_000),
|
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) {
|
function safeJsonParse(input: string) {
|
||||||
@ -83,6 +101,7 @@ export async function POST(request: Request) {
|
|||||||
message: payload.data.message,
|
message: payload.data.message,
|
||||||
|
|
||||||
typeId: payload.data.typeId ?? null,
|
typeId: payload.data.typeId ?? null,
|
||||||
|
customCardId: payload.data.customCardId ?? null,
|
||||||
optionId: payload.data.optionId ?? null,
|
optionId: payload.data.optionId ?? null,
|
||||||
|
|
||||||
ipAddress,
|
ipAddress,
|
||||||
|
|||||||
@ -1,50 +1,32 @@
|
|||||||
"use client";
|
"use client"
|
||||||
|
|
||||||
import { generateAltTextForArtwork } from "@/actions/artworks/generateAltText";
|
|
||||||
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import {
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import MultipleSelector from "@/components/ui/multiselect";
|
import MultipleSelector from "@/components/ui/multiselect";
|
||||||
import {
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import type { ArtTag } from "@/generated/prisma/client";
|
import { ArtTag } from "@/generated/prisma/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
||||||
import type { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTransition } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export default function EditArtworkForm({
|
export default function EditArtworkForm({ artwork, categories, tags }:
|
||||||
artwork,
|
{
|
||||||
categories,
|
artwork: ArtworkWithRelations,
|
||||||
tags,
|
categories: CategoryWithTags[]
|
||||||
}: {
|
tags: ArtTag[]
|
||||||
artwork: ArtworkWithRelations;
|
}) {
|
||||||
categories: CategoryWithTags[];
|
|
||||||
tags: ArtTag[];
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isGeneratingAlt, startAltTransition] = useTransition();
|
|
||||||
const form = useForm<z.infer<typeof artworkSchema>>({
|
const form = useForm<z.infer<typeof artworkSchema>>({
|
||||||
resolver: zodResolver(artworkSchema),
|
resolver: zodResolver(artworkSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -59,21 +41,19 @@ export default function EditArtworkForm({
|
|||||||
notes: artwork.notes || "",
|
notes: artwork.notes || "",
|
||||||
month: artwork.month || undefined,
|
month: artwork.month || undefined,
|
||||||
year: artwork.year || undefined,
|
year: artwork.year || undefined,
|
||||||
creationDate: artwork.creationDate
|
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined,
|
||||||
? new Date(artwork.creationDate)
|
categoryIds: artwork.categories?.map(cat => cat.id) ?? [],
|
||||||
: undefined,
|
tagIds: artwork.tags?.map(tag => tag.id) ?? [],
|
||||||
categoryIds: artwork.categories?.map((cat) => cat.id) ?? [],
|
|
||||||
tagIds: artwork.tags?.map((tag) => tag.id) ?? [],
|
|
||||||
newCategoryNames: [],
|
newCategoryNames: [],
|
||||||
newTagNames: [],
|
newTagNames: []
|
||||||
},
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof artworkSchema>) {
|
async function onSubmit(values: z.infer<typeof artworkSchema>) {
|
||||||
const updatedArtwork = await updateArtwork(values, artwork.id);
|
const updatedArtwork = await updateArtwork(values, artwork.id)
|
||||||
if (updatedArtwork) {
|
if (updatedArtwork) {
|
||||||
toast.success("Artwork updated");
|
toast.success("Artwork updated")
|
||||||
router.push(`/artworks`);
|
router.push(`/artworks`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,42 +80,10 @@ export default function EditArtworkForm({
|
|||||||
name="altText"
|
name="altText"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<FormLabel>Alt Text</FormLabel>
|
<FormLabel>Alt Text</FormLabel>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={isGeneratingAlt}
|
|
||||||
onClick={() =>
|
|
||||||
startAltTransition(async () => {
|
|
||||||
try {
|
|
||||||
const alt = await generateAltTextForArtwork(
|
|
||||||
artwork.id,
|
|
||||||
field.value,
|
|
||||||
);
|
|
||||||
form.setValue("altText", alt, {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
toast.success("Alt text generated");
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast.error("Alt text generation failed");
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isGeneratingAlt ? "Generating..." : "Generate"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Alt for this image" />
|
<Input {...field} placeholder="Alt for this image" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
|
||||||
Generates a caption from the original image. CPU-only can take
|
|
||||||
10–30s.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -147,10 +95,7 @@ export default function EditArtworkForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea {...field} placeholder="A descriptive text to the image" />
|
||||||
{...field}
|
|
||||||
placeholder="A descriptive text to the image"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@ -180,11 +125,9 @@ export default function EditArtworkForm({
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : +e.target.value)
|
||||||
e.target.value === "" ? undefined : +e.target.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -202,11 +145,9 @@ export default function EditArtworkForm({
|
|||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
type="number"
|
type="number"
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
field.onChange(
|
field.onChange(e.target.value === '' ? undefined : +e.target.value)
|
||||||
e.target.value === "" ? undefined : +e.target.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -229,12 +170,10 @@ export default function EditArtworkForm({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pl-3 text-left font-normal",
|
"pl-3 text-left font-normal",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{field.value
|
{field.value ? format(field.value, "PPP") : "Pick a date"}
|
||||||
? format(field.value, "PPP")
|
|
||||||
: "Pick a date"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
@ -243,7 +182,7 @@ export default function EditArtworkForm({
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={field.value}
|
selected={field.value}
|
||||||
onSelect={(date) => {
|
onSelect={(date) => {
|
||||||
field.onChange(date);
|
field.onChange(date)
|
||||||
}}
|
}}
|
||||||
initialFocus
|
initialFocus
|
||||||
fromYear={1990}
|
fromYear={1990}
|
||||||
@ -297,23 +236,17 @@ export default function EditArtworkForm({
|
|||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const values = options.map((o) => o.value);
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
const existingIds = values.filter(
|
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
||||||
(v) => !v.startsWith("__new__:"),
|
|
||||||
);
|
|
||||||
const newNames = values
|
const newNames = values
|
||||||
.filter((v) => v.startsWith("__new__:"))
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
.map((v) => v.replace("__new__:", "").trim())
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
field.onChange(existingIds);
|
field.onChange(existingIds);
|
||||||
form.setValue(
|
form.setValue("newCategoryNames", Array.from(new Set(newNames)), {
|
||||||
"newCategoryNames",
|
|
||||||
Array.from(new Set(newNames)),
|
|
||||||
{
|
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -343,8 +276,7 @@ export default function EditArtworkForm({
|
|||||||
.map((t) => {
|
.map((t) => {
|
||||||
let group = "Other tags";
|
let group = "Other tags";
|
||||||
if (selectedTagIds.includes(t.id)) group = "Selected";
|
if (selectedTagIds.includes(t.id)) group = "Selected";
|
||||||
else if (preferredTagIds.has(t.id))
|
else if (preferredTagIds.has(t.id)) group = "From selected categories";
|
||||||
group = "From selected categories";
|
|
||||||
|
|
||||||
return { label: t.name, value: t.id, group };
|
return { label: t.name, value: t.id, group };
|
||||||
})
|
})
|
||||||
@ -369,19 +301,12 @@ export default function EditArtworkForm({
|
|||||||
<MultipleSelector
|
<MultipleSelector
|
||||||
options={tagOptions}
|
options={tagOptions}
|
||||||
groupBy="group"
|
groupBy="group"
|
||||||
groupOrder={[
|
groupOrder={["Selected", "From selected categories", "Other tags"]}
|
||||||
"Selected",
|
|
||||||
"From selected categories",
|
|
||||||
"Other tags",
|
|
||||||
]}
|
|
||||||
showSelectedInDropdown
|
showSelectedInDropdown
|
||||||
placeholder="Select or type to create tags"
|
placeholder="Select or type to create tags"
|
||||||
hidePlaceholderWhenSelected
|
hidePlaceholderWhenSelected
|
||||||
selectFirstItem
|
selectFirstItem
|
||||||
value={[
|
value={[...selectedExistingOptions, ...selectedNewOptions]}
|
||||||
...selectedExistingOptions,
|
|
||||||
...selectedNewOptions,
|
|
||||||
]}
|
|
||||||
creatable
|
creatable
|
||||||
createOption={(raw) => ({
|
createOption={(raw) => ({
|
||||||
value: `__new__:${raw}`,
|
value: `__new__:${raw}`,
|
||||||
@ -391,23 +316,17 @@ export default function EditArtworkForm({
|
|||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const values = options.map((o) => o.value);
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
const existingIds = values.filter(
|
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
||||||
(v) => !v.startsWith("__new__:"),
|
|
||||||
);
|
|
||||||
const newNames = values
|
const newNames = values
|
||||||
.filter((v) => v.startsWith("__new__:"))
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
.map((v) => v.replace("__new__:", "").trim())
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
field.onChange(existingIds);
|
field.onChange(existingIds);
|
||||||
form.setValue(
|
form.setValue("newTagNames", Array.from(new Set(newNames)), {
|
||||||
"newTagNames",
|
|
||||||
Array.from(new Set(newNames)),
|
|
||||||
{
|
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
shouldValidate: true,
|
shouldValidate: true,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -428,10 +347,7 @@ export default function EditArtworkForm({
|
|||||||
<FormDescription></FormDescription>
|
<FormDescription></FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -443,15 +359,10 @@ export default function EditArtworkForm({
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>NSFW</FormLabel>
|
<FormLabel>NSFW</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>This image contains sensitive or adult content.</FormDescription>
|
||||||
This image contains sensitive or adult content.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -463,15 +374,10 @@ export default function EditArtworkForm({
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Publish</FormLabel>
|
<FormLabel>Publish</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>Will this image be published.</FormDescription>
|
||||||
Will this image be published.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -483,15 +389,10 @@ export default function EditArtworkForm({
|
|||||||
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
<FormItem className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Set as header image</FormLabel>
|
<FormLabel>Set as header image</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>Will be the main banner image. Choose a fitting one.</FormDescription>
|
||||||
Will be the main banner image. Choose a fitting one.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@ -543,16 +444,10 @@ export default function EditArtworkForm({
|
|||||||
/> */}
|
/> */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Button type="submit">Submit</Button>
|
<Button type="submit">Submit</Button>
|
||||||
<Button
|
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
|
||||||
type="reset"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => router.back()}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
116
src/components/commissions/customCards/CustomCardImagePicker.tsx
Normal file
116
src/components/commissions/customCards/CustomCardImagePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/components/commissions/customCards/EditCustomCardForm.tsx
Normal file
187
src/components/commissions/customCards/EditCustomCardForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
src/components/commissions/customCards/ListCustomCards.tsx
Normal file
219
src/components/commissions/customCards/ListCustomCards.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/components/commissions/customCards/NewCustomCardForm.tsx
Normal file
147
src/components/commissions/customCards/NewCustomCardForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -34,6 +34,10 @@ const commissionItems = [
|
|||||||
title: "Types",
|
title: "Types",
|
||||||
href: "/commissions/types",
|
href: "/commissions/types",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Custom Cards",
|
||||||
|
href: "/commissions/custom-cards",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Guidelines",
|
title: "Guidelines",
|
||||||
href: "/commissions/guidelines",
|
href: "/commissions/guidelines",
|
||||||
|
|||||||
@ -45,6 +45,7 @@ export const adminNav: AdminNavGroup[] = [
|
|||||||
{ title: "Requests", href: "/commissions/requests" },
|
{ title: "Requests", href: "/commissions/requests" },
|
||||||
{ title: "Board", href: "/commissions/kanban" },
|
{ title: "Board", href: "/commissions/kanban" },
|
||||||
{ title: "Types", href: "/commissions/types" },
|
{ title: "Types", href: "/commissions/types" },
|
||||||
|
{ title: "Custom Cards", href: "/commissions/custom-cards" },
|
||||||
{ title: "TypeOptions", href: "/commissions/types/options" },
|
{ title: "TypeOptions", href: "/commissions/types/options" },
|
||||||
{ title: "TypeExtras", href: "/commissions/types/extras" },
|
{ title: "TypeExtras", href: "/commissions/types/extras" },
|
||||||
{ title: "Guidelines", href: "/commissions/guidelines" },
|
{ title: "Guidelines", href: "/commissions/guidelines" },
|
||||||
|
|||||||
35
src/schemas/commissionCustomCard.ts
Normal file
35
src/schemas/commissionCustomCard.ts
Normal 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 '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<typeof commissionCustomCardSchema>;
|
||||||
Reference in New Issue
Block a user