Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
940e934237
|
|||
|
aa95635e3e
|
|||
|
1940867519
|
|||
|
3e6d045cbd
|
|||
|
c712f31759
|
|||
|
eb8dcd54a8
|
|||
|
030065631c
|
|||
|
5a3e567ed5
|
@ -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[]
|
||||||
@ -399,6 +461,7 @@ model CommissionGuidelines {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
markdown String
|
markdown String
|
||||||
|
exampleImageUrl String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
|
|||||||
@ -2,17 +2,9 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
|
||||||
* Server action
|
|
||||||
* Forwards a multipart/form-data request (payload + files[])
|
|
||||||
* from the public app to the admin app's public commissions endpoint.
|
|
||||||
*
|
|
||||||
* Server-only env required:
|
|
||||||
* ADMIN_URL=https://admin.domain.com
|
|
||||||
*/
|
|
||||||
|
|
||||||
const submitPayloadSchema = z.object({
|
const submitPayloadSchema = z.object({
|
||||||
typeId: z.string().optional().nullable(),
|
typeId: z.string().optional().nullable(),
|
||||||
|
customCardId: z.string().optional().nullable(),
|
||||||
optionId: z.string().optional().nullable(),
|
optionId: z.string().optional().nullable(),
|
||||||
extraIds: z.array(z.string()).default([]),
|
extraIds: z.array(z.string()).default([]),
|
||||||
|
|
||||||
@ -20,6 +12,23 @@ const submitPayloadSchema = z.object({
|
|||||||
customerEmail: z.string().email(),
|
customerEmail: z.string().email(),
|
||||||
customerSocials: z.string().optional().nullable(),
|
customerSocials: z.string().optional().nullable(),
|
||||||
message: z.string().min(1),
|
message: z.string().min(1),
|
||||||
|
}).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",
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SubmitCommissionPayload = z.infer<typeof submitPayloadSchema>;
|
export type SubmitCommissionPayload = z.infer<typeof submitPayloadSchema>;
|
||||||
@ -36,7 +45,6 @@ export async function submitCommissionRequest(input: {
|
|||||||
const payload = submitPayloadSchema.parse(input.payload);
|
const payload = submitPayloadSchema.parse(input.payload);
|
||||||
const files = input.files ?? [];
|
const files = input.files ?? [];
|
||||||
|
|
||||||
// Optional safety limits
|
|
||||||
const MAX_FILES = 10;
|
const MAX_FILES = 10;
|
||||||
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
|
const MAX_BYTES_EACH = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
@ -70,7 +78,6 @@ export async function submitCommissionRequest(input: {
|
|||||||
const raw = await res.text().catch(() => "");
|
const raw = await res.text().catch(() => "");
|
||||||
const statusLine = `${res.status} ${res.statusText || ""}`.trim();
|
const statusLine = `${res.status} ${res.statusText || ""}`.trim();
|
||||||
|
|
||||||
// Show something useful even if raw is empty
|
|
||||||
let message = `Admin API error: ${statusLine}`;
|
let message = `Admin API error: ${statusLine}`;
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
@ -87,12 +94,9 @@ export async function submitCommissionRequest(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log full body server-side for debugging (safe; this is server-only)
|
|
||||||
console.error("[submitCommissionRequest] upstream error", { statusLine, raw });
|
console.error("[submitCommissionRequest] upstream error", { statusLine, raw });
|
||||||
|
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expected response: { id: string; createdAt: string }
|
|
||||||
return (await res.json()) as { id: string; createdAt: string };
|
return (await res.json()) as { id: string; createdAt: string };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
import type { PortfolioFilters } from "@/actions/portfolio/getPortfolioArtworksPage";
|
||||||
import { Prisma } from "@/generated/prisma/browser";
|
import type { Prisma } from "@/generated/prisma/browser";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
function coerceYear(y: PortfolioFilters["year"]) {
|
function coerceYear(y: PortfolioFilters["year"]) {
|
||||||
|
|||||||
@ -107,7 +107,6 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
.filter((y): y is number => typeof y === "number")
|
.filter((y): y is number => typeof y === "number")
|
||||||
.sort((a, b) => b - a);
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
// Segment logic (sortKey != null first, then null)
|
|
||||||
const inNullSegment = cursor?.afterSortKey === null;
|
const inNullSegment = cursor?.afterSortKey === null;
|
||||||
|
|
||||||
const select = {
|
const select = {
|
||||||
@ -180,7 +179,6 @@ export async function getPortfolioArtworksPage(args: {
|
|||||||
if (!last) {
|
if (!last) {
|
||||||
return { items, nextCursor: null, total, years, albums };
|
return { items, nextCursor: null, total, years, albums };
|
||||||
}
|
}
|
||||||
// last.sortKey can be null only in null-segment, which we are not in here.
|
|
||||||
if (last.sortKey == null) {
|
if (last.sortKey == null) {
|
||||||
return { items, nextCursor: null, total, years, albums };
|
return { items, nextCursor: null, total, years, albums };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +1,82 @@
|
|||||||
import { CommissionCard } from "@/components/commissions/CommissionCard";
|
import { CommissionCard } from "@/components/commissions/CommissionCard";
|
||||||
|
import { CommissionCustomCard } from "@/components/commissions/CommissionCustomCard";
|
||||||
import CommissionGuidelines from "@/components/commissions/CommissionGuidelines";
|
import CommissionGuidelines from "@/components/commissions/CommissionGuidelines";
|
||||||
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
|
import { CommissionOrderForm } from "@/components/commissions/CommissionOrderForm";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
export default async function CommissionsPage() {
|
export default async function CommissionsPage() {
|
||||||
const commissions = await prisma.commissionType.findMany({
|
const [commissions, customCards, guidelines] = await Promise.all([
|
||||||
include: {
|
prisma.commissionType.findMany({
|
||||||
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
include: {
|
||||||
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||||
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
|
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||||
},
|
customInputs: { include: { customInput: true }, orderBy: { sortIndex: "asc" } },
|
||||||
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
},
|
||||||
})
|
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.commissionCustomCard.findMany({
|
||||||
|
where: { isVisible: true },
|
||||||
|
include: {
|
||||||
|
options: { include: { option: true }, orderBy: { sortIndex: "asc" } },
|
||||||
|
extras: { include: { extra: true }, orderBy: { sortIndex: "asc" } },
|
||||||
|
},
|
||||||
|
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.commissionGuidelines.findFirst({
|
||||||
|
where: { isActive: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { exampleImageUrl: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-8 flex flex-col gap-8">
|
<div className="mx-auto w-full max-w-6xl px-4 py-8 flex flex-col gap-8">
|
||||||
<h1 className="text-3xl font-bold">Commission Pricing</h1>
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h1 className="text-3xl font-bold">Commission Pricing</h1>
|
||||||
|
{guidelines?.exampleImageUrl ? (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="secondary">View example</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>Commission example</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex max-h-[85vh] max-w-[85vw] items-center justify-center rounded-xl border-border/60 bg-muted p-2 shadow-2xl">
|
||||||
|
<Image
|
||||||
|
src={guidelines.exampleImageUrl}
|
||||||
|
alt="Commission example"
|
||||||
|
width={1600}
|
||||||
|
height={1200}
|
||||||
|
sizes="85vw"
|
||||||
|
className="h-auto max-h-[85vh] w-auto max-w-[85vw] rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-start">
|
||||||
{commissions.map((commission) => (
|
{commissions.map((commission) => (
|
||||||
<CommissionCard key={commission.id} commission={commission} />
|
<CommissionCard key={commission.id} commission={commission} />
|
||||||
))}
|
))}
|
||||||
|
{customCards.map((card) => (
|
||||||
|
<CommissionCustomCard key={card.id} card={card} />
|
||||||
|
))}
|
||||||
<CommissionGuidelines />
|
<CommissionGuidelines />
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<h2 className="text-2xl font-semibold">Request a Commission</h2>
|
<h2 className="text-2xl font-semibold">Request a Commission</h2>
|
||||||
<CommissionOrderForm types={commissions} />
|
<CommissionOrderForm types={commissions} customCards={customCards} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
122
src/app/(normal)/commissions/status/page.tsx
Normal file
122
src/app/(normal)/commissions/status/page.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
ACCEPTED: "bg-sky-500/15 text-sky-300 border-sky-500/30",
|
||||||
|
INPROGRESS: "bg-amber-500/15 text-amber-300 border-amber-500/30",
|
||||||
|
COMPLETED: "bg-emerald-500/15 text-emerald-300 border-emerald-500/30",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
ACCEPTED: "Accepted",
|
||||||
|
INPROGRESS: "In progress",
|
||||||
|
COMPLETED: "Completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function CommissionStatusPage() {
|
||||||
|
const [queueItems, doneItems] = await Promise.all([
|
||||||
|
prisma.commissionRequest.findMany({
|
||||||
|
where: { status: { in: ["ACCEPTED", "INPROGRESS"] } },
|
||||||
|
include: { type: true, option: true, extras: true, customCard: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.commissionRequest.findMany({
|
||||||
|
where: { status: "COMPLETED" },
|
||||||
|
include: { type: true, option: true, extras: true, customCard: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-6xl px-4 py-8 flex flex-col gap-8">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-bold">Commission Status</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<section className="rounded-2xl border border-border/60 bg-muted/20 p-4 sm:p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Commissions Queue</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Accepted and in progress</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{queueItems.length > 0 ? (
|
||||||
|
queueItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-xl border border-border/60 bg-background p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-lg">{item.customerName}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.customCard?.name ?? item.type?.name ?? "Custom"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.option?.name ?? "Base option"}
|
||||||
|
</Badge>
|
||||||
|
{item.extras.map((extra) => (
|
||||||
|
<Badge key={extra.id} variant="secondary">
|
||||||
|
{extra.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{item.extras.length === 0 ? (
|
||||||
|
<Badge variant="secondary">No extras</Badge>
|
||||||
|
) : null}
|
||||||
|
<Badge className={statusStyles[item.status] ?? ""}>
|
||||||
|
{statusLabels[item.status] ?? item.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-border/60 bg-background p-4 text-sm text-muted-foreground">
|
||||||
|
No public items yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border/60 bg-muted/20 p-4 sm:p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">Done</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{doneItems.length > 0 ? (
|
||||||
|
doneItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-xl border border-border/60 bg-background p-4 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-lg">{item.customerName}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.customCard?.name ?? item.type?.name ?? "Custom"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.option?.name ?? "Base option"}
|
||||||
|
</Badge>
|
||||||
|
{item.extras.map((extra) => (
|
||||||
|
<Badge key={extra.id} variant="secondary">
|
||||||
|
{extra.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{item.extras.length === 0 ? (
|
||||||
|
<Badge variant="secondary">No extras</Badge>
|
||||||
|
) : null}
|
||||||
|
<Badge className={statusStyles[item.status] ?? ""}>
|
||||||
|
{statusLabels[item.status] ?? item.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dashed border-border/60 bg-background p-4 text-sm text-muted-foreground">
|
||||||
|
No completed items yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,29 +19,6 @@ export default function Home() {
|
|||||||
<SocialLinks />
|
<SocialLinks />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Section Cards */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
|
||||||
{/* <p>
|
|
||||||
If you want to commission me you can find all the information you need under following link: <a href="https://linktr.ee/gaertan" target="_blank">Linktree</a>
|
|
||||||
</p> */}
|
|
||||||
{/* {sections.map((section) => (
|
|
||||||
<Link href={section.href} key={section.title}>
|
|
||||||
<Card className="hover:shadow-xl transition-shadow group">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-lg">
|
|
||||||
<section.icon className="w-5 h-5 text-muted-foreground group-hover:text-primary" />
|
|
||||||
{section.title}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="text-sm text-muted-foreground">
|
|
||||||
{section.description}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))} */}
|
|
||||||
</div>
|
|
||||||
</div >
|
</div >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,8 +6,6 @@ export default async function TosPage() {
|
|||||||
orderBy: [{ version: "desc" }],
|
orderBy: [{ version: "desc" }],
|
||||||
})
|
})
|
||||||
|
|
||||||
// console.log(tos?.markdown)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
<div className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||||
<div className="markdown">
|
<div className="markdown">
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export async function GET(_req: NextRequest, context: { params: Promise<{ key: s
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Cache-Control": "public, max-age=3600",
|
"Cache-Control": "public, max-age=3600",
|
||||||
"Content-Disposition": "inline", // use 'attachment' to force download
|
"Content-Disposition": "inline",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -48,119 +48,33 @@
|
|||||||
--color-hover-foreground: var(--hover-foreground);
|
--color-hover-foreground: var(--hover-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* :root {
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.145 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
} */
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(0.985 0.012 85);
|
||||||
/* Light: warm paper + graphite */
|
--foreground: oklch(0.18 0.02 35);
|
||||||
--background: oklch(0.985 0.012 85); /* warm off-white */
|
--card: oklch(0.992 0.008 85);
|
||||||
--foreground: oklch(0.18 0.02 35); /* graphite */
|
|
||||||
|
|
||||||
--card: oklch(0.992 0.008 85); /* slightly lifted paper */
|
|
||||||
--card-foreground: var(--foreground);
|
--card-foreground: var(--foreground);
|
||||||
|
|
||||||
--popover: oklch(0.992 0.008 85);
|
--popover: oklch(0.992 0.008 85);
|
||||||
--popover-foreground: var(--foreground);
|
--popover-foreground: var(--foreground);
|
||||||
|
|
||||||
/* Primary: deep ink / indigo (artist-y but still neutral enough) */
|
|
||||||
--primary: oklch(0.32 0.06 260);
|
--primary: oklch(0.32 0.06 260);
|
||||||
--primary-foreground: oklch(0.985 0.012 85);
|
--primary-foreground: oklch(0.985 0.012 85);
|
||||||
|
|
||||||
/* Secondary/muted/accent: warm washes */
|
|
||||||
--secondary: oklch(0.96 0.015 85);
|
--secondary: oklch(0.96 0.015 85);
|
||||||
--secondary-foreground: oklch(0.22 0.02 35);
|
--secondary-foreground: oklch(0.22 0.02 35);
|
||||||
|
|
||||||
--muted: oklch(0.955 0.012 85);
|
--muted: oklch(0.955 0.012 85);
|
||||||
--muted-foreground: oklch(0.46 0.02 35);
|
--muted-foreground: oklch(0.46 0.02 35);
|
||||||
|
--accent: oklch(0.95 0.02 110);
|
||||||
--accent: oklch(0.95 0.02 110); /* subtle “wash” */
|
|
||||||
--accent-foreground: oklch(0.22 0.02 35);
|
--accent-foreground: oklch(0.22 0.02 35);
|
||||||
|
|
||||||
--destructive: oklch(0.58 0.22 27.325);
|
--destructive: oklch(0.58 0.22 27.325);
|
||||||
|
--border: oklch(0.90 0.02 85);
|
||||||
--border: oklch(0.90 0.02 85); /* warm border */
|
|
||||||
--input: oklch(0.90 0.02 85);
|
--input: oklch(0.90 0.02 85);
|
||||||
--ring: oklch(0.55 0.07 260); /* ties to primary */
|
--ring: oklch(0.55 0.07 260);
|
||||||
|
--hover: oklch(0.94 0.015 255);
|
||||||
--hover: oklch(0.94 0.015 255); /* subtle cool lift */
|
|
||||||
--hover-foreground: var(--foreground);
|
--hover-foreground: var(--foreground);
|
||||||
|
|
||||||
/* charts can stay, or we can harmonize later */
|
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
|
||||||
/* sidebar inherits the same “paper” idea */
|
|
||||||
--sidebar: oklch(0.975 0.012 85);
|
--sidebar: oklch(0.975 0.012 85);
|
||||||
--sidebar-foreground: var(--foreground);
|
--sidebar-foreground: var(--foreground);
|
||||||
--sidebar-primary: var(--primary);
|
--sidebar-primary: var(--primary);
|
||||||
@ -172,36 +86,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Inky navy background (clearly not neutral) */
|
--background: oklch(0.2223 0.0060 271.1393);
|
||||||
--background: oklch(15.774% 0.03835 263.588);
|
--foreground: oklch(0.9551 0 0);
|
||||||
--foreground: oklch(0.95 0.012 85);
|
--card: oklch(0.2568 0.0076 274.6528);
|
||||||
|
--card-foreground: oklch(0.9551 0 0);
|
||||||
/* Surfaces */
|
--popover: oklch(0.2568 0.0076 274.6528);
|
||||||
--card: oklch(0.155 0.03 255);
|
--popover-foreground: oklch(0.9551 0 0);
|
||||||
--card-foreground: var(--foreground);
|
--primary: oklch(0.6132 0.2294 291.7437);
|
||||||
|
--primary-foreground: oklch(1.0000 0 0);
|
||||||
--popover: oklch(0.155 0.03 255);
|
--secondary: oklch(0.2940 0.0130 272.9312);
|
||||||
--popover-foreground: var(--foreground);
|
--secondary-foreground: oklch(0.9551 0 0);
|
||||||
|
--muted: oklch(0.2940 0.0130 272.9312);
|
||||||
/* Primary accent stays “artist ink” */
|
--muted-foreground: oklch(0.7058 0 0);
|
||||||
--primary: oklch(0.78 0.11 255);
|
--accent: oklch(0.2795 0.0368 260.0310);
|
||||||
--primary-foreground: oklch(0.13 0.03 255);
|
--accent-foreground: oklch(0.7857 0.1153 246.6596);
|
||||||
|
--destructive: oklch(0.7106 0.1661 22.2162);
|
||||||
--secondary: oklch(0.19 0.025 255);
|
--destructive-foreground: oklch(1.0000 0 0);
|
||||||
--secondary-foreground: var(--foreground);
|
--border: oklch(0.3289 0.0092 268.3843);
|
||||||
|
--input: oklch(0.3289 0.0092 268.3843);
|
||||||
--muted: oklch(0.19 0.025 255);
|
--ring: oklch(0.6132 0.2294 291.7437);
|
||||||
--muted-foreground: oklch(0.74 0.02 85);
|
--chart-1: oklch(0.8003 0.1821 151.7110);
|
||||||
|
--chart-2: oklch(0.6132 0.2294 291.7437);
|
||||||
--accent: oklch(0.22 0.03 110);
|
--chart-3: oklch(0.8077 0.1035 19.5706);
|
||||||
--accent-foreground: var(--foreground);
|
--chart-4: oklch(0.6691 0.1569 260.1063);
|
||||||
|
--chart-5: oklch(0.7058 0 0);
|
||||||
--border: oklch(0.40 0.03 255 / 55%);
|
--sidebar: oklch(0.2011 0.0039 286.0396);
|
||||||
--input: oklch(0.40 0.03 255 / 65%);
|
--sidebar-foreground: oklch(0.9551 0 0);
|
||||||
--ring: oklch(0.65 0.10 255);
|
--sidebar-primary: oklch(0.6132 0.2294 291.7437);
|
||||||
|
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||||
--hover: oklch(28.783% 0.03139 250.817);
|
--sidebar-accent: oklch(0.2940 0.0130 272.9312);
|
||||||
--hover-foreground: var(--foreground);
|
--sidebar-accent-foreground: oklch(0.6132 0.2294 291.7437);
|
||||||
|
--sidebar-border: oklch(0.3289 0.0092 268.3843);
|
||||||
|
--sidebar-ring: oklch(0.6132 0.2294 291.7437);
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown {
|
.markdown {
|
||||||
@ -260,10 +176,6 @@
|
|||||||
@apply line-through text-muted-foreground;
|
@apply line-through text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* div:hover {
|
|
||||||
border-color: var(--hover-border-color);
|
|
||||||
} */
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@ -276,7 +188,7 @@
|
|||||||
}
|
}
|
||||||
.dark body {
|
.dark body {
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(1200px 700px at 15% -10%, oklch(0.55 0.14 255 / 16%), transparent 60%),
|
radial-gradient(1200px 700px at 15% -10%, oklch(0.35 0.06 35 / 10%), transparent 60%),
|
||||||
radial-gradient(900px 600px at 85% 0%, oklch(0.50 0.12 110 / 10%), transparent 55%);
|
radial-gradient(900px 600px at 85% 0%, oklch(0.30 0.05 255 / 6%), transparent 55%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,11 @@ const geistMono = Geist_Mono({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Gaertan Art",
|
title: "Gaertan Art",
|
||||||
description: "Portfolio, Artworks and Commission Requests",
|
description: "Portfolio, Artworks and Commission Requests",
|
||||||
|
alternates: {
|
||||||
|
types: {
|
||||||
|
"application/rss+xml": "/rss.xml",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
71
src/app/rss.xml/route.ts
Normal file
71
src/app/rss.xml/route.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const BASE_URL = `${process.env.FEED_URL}`
|
||||||
|
|
||||||
|
function escapeXml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const items = await prisma.artwork.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
description: true,
|
||||||
|
altText: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastBuildDate =
|
||||||
|
items[0]?.updatedAt?.toUTCString() ?? new Date().toUTCString();
|
||||||
|
|
||||||
|
const itemXml = items
|
||||||
|
.map((item) => {
|
||||||
|
const title = escapeXml(item.name || "Artwork");
|
||||||
|
const description = escapeXml(
|
||||||
|
item.description || item.altText || item.name || "Artwork"
|
||||||
|
);
|
||||||
|
const link = `${BASE_URL}/artworks/single/${item.id}`;
|
||||||
|
const pubDate = item.createdAt.toUTCString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
"<item>",
|
||||||
|
`<title>${title}</title>`,
|
||||||
|
`<link>${link}</link>`,
|
||||||
|
`<guid isPermaLink="true">${link}</guid>`,
|
||||||
|
`<description>${description}</description>`,
|
||||||
|
`<pubDate>${pubDate}</pubDate>`,
|
||||||
|
"</item>",
|
||||||
|
].join("");
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<rss version="2.0">',
|
||||||
|
"<channel>",
|
||||||
|
"<title>Gaertan Art - Latest Artworks</title>",
|
||||||
|
`<link>${BASE_URL}</link>`,
|
||||||
|
"<description>Ten newest artworks from Gaertan Art.</description>",
|
||||||
|
`<lastBuildDate>${lastBuildDate}</lastBuildDate>`,
|
||||||
|
itemXml,
|
||||||
|
"</channel>",
|
||||||
|
"</rss>",
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/rss+xml; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -3,12 +3,20 @@ import {
|
|||||||
siLinktree,
|
siLinktree,
|
||||||
siMastodon,
|
siMastodon,
|
||||||
siPaypal,
|
siPaypal,
|
||||||
|
siRss,
|
||||||
siTelegram,
|
siTelegram,
|
||||||
siTwitch,
|
siTwitch,
|
||||||
type SimpleIcon,
|
type SimpleIcon,
|
||||||
} from "simple-icons";
|
} from "simple-icons";
|
||||||
|
|
||||||
type SocialKey = "paypal" | "telegram" | "mastodon" | "bluesky" | "linktree" | "twitch";
|
type SocialKey =
|
||||||
|
| "paypal"
|
||||||
|
| "telegram"
|
||||||
|
| "mastodon"
|
||||||
|
| "bluesky"
|
||||||
|
| "linktree"
|
||||||
|
| "twitch"
|
||||||
|
| "rss";
|
||||||
|
|
||||||
const SOCIALS: Record<
|
const SOCIALS: Record<
|
||||||
SocialKey,
|
SocialKey,
|
||||||
@ -43,7 +51,12 @@ const SOCIALS: Record<
|
|||||||
label: "Twitch",
|
label: "Twitch",
|
||||||
icon: siTwitch,
|
icon: siTwitch,
|
||||||
href: "https://www.twitch.tv/gaertan_art",
|
href: "https://www.twitch.tv/gaertan_art",
|
||||||
}
|
},
|
||||||
|
rss: {
|
||||||
|
label: "RSS",
|
||||||
|
icon: siRss,
|
||||||
|
href: `/rss.xml`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
||||||
@ -54,13 +67,22 @@ function BrandSvg({ icon }: { icon: SimpleIcon }) {
|
|||||||
className="h-5 w-5 fill-current"
|
className="h-5 w-5 fill-current"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
|
// biome-ignore lint: lint/security/noDangerouslySetInnerHtml
|
||||||
dangerouslySetInnerHTML={{ __html: icon.svg }}
|
dangerouslySetInnerHTML={{ __html: icon.svg }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SocialLinks({
|
export function SocialLinks({
|
||||||
items = ["paypal", "telegram", "mastodon", "bluesky", "linktree", "twitch"],
|
items = [
|
||||||
|
"paypal",
|
||||||
|
"telegram",
|
||||||
|
"mastodon",
|
||||||
|
"bluesky",
|
||||||
|
"linktree",
|
||||||
|
"twitch",
|
||||||
|
"rss",
|
||||||
|
],
|
||||||
size = "md",
|
size = "md",
|
||||||
}: {
|
}: {
|
||||||
items?: SocialKey[];
|
items?: SocialKey[];
|
||||||
|
|||||||
@ -27,9 +27,6 @@ export default function ArtworkTimelapseViewer({
|
|||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
// IMPORTANT:
|
|
||||||
// This assumes your existing `/api/image/[...key]` can stream arbitrary S3 keys.
|
|
||||||
// If your route expects a different format, adjust this in one place.
|
|
||||||
const src = `/api/image/${encodeURI(timelapse.s3Key)}`;
|
const src = `/api/image/${encodeURI(timelapse.s3Key)}`;
|
||||||
|
|
||||||
// Minimal empty captions track (satisfies jsx-a11y/media-has-caption)
|
// Minimal empty captions track (satisfies jsx-a11y/media-has-caption)
|
||||||
@ -46,7 +43,6 @@ export default function ArtworkTimelapseViewer({
|
|||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Only render video when open (prevents unnecessary network / CPU). */}
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<video
|
<video
|
||||||
@ -61,7 +57,7 @@ export default function ArtworkTimelapseViewer({
|
|||||||
</video>
|
</video>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{timelapse.fileName ? timelapse.fileName : timelapse.s3Key}
|
{/* {timelapse.fileName ? timelapse.fileName : timelapse.s3Key} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export default function RawCloseButton({ targetHref }: RawCloseButtonProps) {
|
|||||||
onClick={() => router.push(targetHref)}
|
onClick={() => router.push(targetHref)}
|
||||||
className="absolute top-4 right-4 z-50 rounded-md bg-background/80 p-2 hover:bg-background/60 transition"
|
className="absolute top-4 right-4 z-50 rounded-md bg-background/80 p-2 hover:bg-background/60 transition"
|
||||||
title="Close full view (ESC)"
|
title="Close full view (ESC)"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -25,7 +25,6 @@ type Tag = {
|
|||||||
slug: string;
|
slug: string;
|
||||||
sortIndex: number;
|
sortIndex: number;
|
||||||
parentId: string | null;
|
parentId: string | null;
|
||||||
// these may exist, but we do NOT rely on them:
|
|
||||||
parent?: { id: string; name: string; slug: string; sortIndex: number } | null;
|
parent?: { id: string; name: string; slug: string; sortIndex: number } | null;
|
||||||
children?: { id: string; name: string; slug: string; sortIndex: number; parentId: string | null }[];
|
children?: { id: string; name: string; slug: string; sortIndex: number; parentId: string | null }[];
|
||||||
};
|
};
|
||||||
@ -65,7 +64,6 @@ export default function TagFilterDialog({
|
|||||||
|
|
||||||
const byId = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
|
const byId = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
|
||||||
|
|
||||||
// Build children mapping from the flat list: parentId -> Tag[]
|
|
||||||
const childrenByParentId = useMemo(() => {
|
const childrenByParentId = useMemo(() => {
|
||||||
const map = new Map<string, Tag[]>();
|
const map = new Map<string, Tag[]>();
|
||||||
for (const t of tags) {
|
for (const t of tags) {
|
||||||
@ -74,7 +72,6 @@ export default function TagFilterDialog({
|
|||||||
arr.push(t);
|
arr.push(t);
|
||||||
map.set(t.parentId, arr);
|
map.set(t.parentId, arr);
|
||||||
}
|
}
|
||||||
// sort each child list
|
|
||||||
for (const [k, arr] of map) {
|
for (const [k, arr] of map) {
|
||||||
map.set(k, arr.slice().sort(sortTags));
|
map.set(k, arr.slice().sort(sortTags));
|
||||||
}
|
}
|
||||||
@ -101,7 +98,6 @@ export default function TagFilterDialog({
|
|||||||
const s = new Set(prev);
|
const s = new Set(prev);
|
||||||
if (next) {
|
if (next) {
|
||||||
s.add(parent.slug);
|
s.add(parent.slug);
|
||||||
// when selecting parent, remove child selections (redundant)
|
|
||||||
for (const c of children) s.delete(c.slug);
|
for (const c of children) s.delete(c.slug);
|
||||||
} else {
|
} else {
|
||||||
s.delete(parent.slug);
|
s.delete(parent.slug);
|
||||||
@ -188,9 +184,6 @@ export default function TagFilterDialog({
|
|||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate font-medium">{p.name}</div>
|
<div className="truncate font-medium">{p.name}</div>
|
||||||
{/* <div className="text-xs text-muted-foreground">
|
|
||||||
{children.length ? "Parent tag" : "Tag"}
|
|
||||||
</div> */}
|
|
||||||
</div>
|
</div>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@ -231,11 +224,6 @@ export default function TagFilterDialog({
|
|||||||
|
|
||||||
{orphanChildren.length ? (
|
{orphanChildren.length ? (
|
||||||
<div className="rounded-lg border p-4">
|
<div className="rounded-lg border p-4">
|
||||||
{/* <div className="mb-2 font-medium">Other tags</div> */}
|
|
||||||
{/* <div className="mb-3 text-xs text-muted-foreground">
|
|
||||||
These tags are not currently assigned to a visible parent.
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
{orphanChildren.map((t) => {
|
{orphanChildren.map((t) => {
|
||||||
const checked = selectedSet.has(t.slug);
|
const checked = selectedSet.has(t.slug);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
import type { CommissionExtra, CommissionOption, CommissionType, CommissionTypeExtra, CommissionTypeOption } from "@/generated/prisma/client"
|
||||||
|
|
||||||
type CommissionTypeWithItems = CommissionType & {
|
type CommissionTypeWithItems = CommissionType & {
|
||||||
options: (CommissionTypeOption & {
|
options: (CommissionTypeOption & {
|
||||||
|
|||||||
140
src/components/commissions/CommissionCustomCard.tsx
Normal file
140
src/components/commissions/CommissionCustomCard.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type CustomCardOption = {
|
||||||
|
id: string;
|
||||||
|
price: number | null;
|
||||||
|
pricePercent: number | null;
|
||||||
|
priceRange: string | null;
|
||||||
|
option: { name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CustomCardExtra = {
|
||||||
|
id: 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;
|
||||||
|
isSpecialOffer: boolean;
|
||||||
|
options: CustomCardOption[];
|
||||||
|
extras: CustomCardExtra[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommissionCustomCard({
|
||||||
|
card,
|
||||||
|
}: {
|
||||||
|
card: CommissionCustomCardWithItems;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<Card className="flex flex-col flex-1 relative overflow-hidden border-2 border-primary/50 shadow-sm">
|
||||||
|
{card.isSpecialOffer ? (
|
||||||
|
<div className="pointer-events-none absolute right-0 top-0 z-10">
|
||||||
|
<div className="absolute right-0 top-16 h-7 w-36 origin-top-right translate-x-10 rotate-45 bg-primary text-primary-foreground shadow-md">
|
||||||
|
<span className="flex h-full w-full items-center justify-center text-xs font-semibold uppercase tracking-wide">
|
||||||
|
Special
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<CardHeader className="gap-2">
|
||||||
|
<CardTitle className="text-xl font-bold">{card.name}</CardTitle>
|
||||||
|
<p className="text-muted-foreground text-sm">{card.description}</p>
|
||||||
|
{card.referenceImageUrl ? (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative overflow-hidden rounded-lg border border-border/60 bg-muted/40"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={card.referenceImageUrl}
|
||||||
|
alt={`${card.name} reference`}
|
||||||
|
width={800}
|
||||||
|
height={600}
|
||||||
|
sizes="(max-width: 768px) 90vw, 400px"
|
||||||
|
className="h-auto w-full object-cover transition-transform duration-200 group-hover:scale-[1.02]"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-x-0 bottom-0 bg-background/70 px-2 py-1 text-xs text-foreground/80">
|
||||||
|
Click to enlarge
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="flex w-auto! max-w-[95vw]! flex-col p-4 sm:p-6">
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{card.name} reference</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex max-h-[85vh] max-w-[85vw] items-center justify-center rounded-xl border-border/60 bg-muted p-2 shadow-2xl">
|
||||||
|
<Image
|
||||||
|
src={card.referenceImageUrl}
|
||||||
|
alt={`${card.name} reference`}
|
||||||
|
width={1600}
|
||||||
|
height={1200}
|
||||||
|
sizes="85vw"
|
||||||
|
className="h-auto max-h-[85vh] w-auto max-w-[85vw] rounded-lg object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
) : 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((option) => (
|
||||||
|
<li key={option.id}>
|
||||||
|
{option.option?.name}:{" "}
|
||||||
|
{option.price && option.price !== 0
|
||||||
|
? `${option.price}€`
|
||||||
|
: option.pricePercent
|
||||||
|
? `+${option.pricePercent}%`
|
||||||
|
: option.priceRange && option.priceRange !== "0–0"
|
||||||
|
? `${option.priceRange}€`
|
||||||
|
: "Included"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{card.extras.length > 0 ? (
|
||||||
|
<h4 className="font-semibold">Extras</h4>
|
||||||
|
) : null}
|
||||||
|
<ul className="pl-4 list-disc">
|
||||||
|
{card.extras.map((extra) => (
|
||||||
|
<li key={extra.id}>
|
||||||
|
{extra.extra?.name}:{" "}
|
||||||
|
{extra.price && extra.price !== 0
|
||||||
|
? `${extra.price}€`
|
||||||
|
: extra.pricePercent
|
||||||
|
? `+${extra.pricePercent}%`
|
||||||
|
: extra.priceRange && extra.priceRange !== "0–0"
|
||||||
|
? `${extra.priceRange}€`
|
||||||
|
: "Included"}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -12,8 +12,11 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import type {
|
||||||
CommissionCustomInput,
|
CommissionCustomInput,
|
||||||
|
CommissionCustomCard,
|
||||||
|
CommissionCustomCardExtra,
|
||||||
|
CommissionCustomCardOption,
|
||||||
CommissionExtra,
|
CommissionExtra,
|
||||||
CommissionOption,
|
CommissionOption,
|
||||||
CommissionType,
|
CommissionType,
|
||||||
@ -28,7 +31,7 @@ import Link from "next/link";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import * as z from "zod/v4";
|
import type * as z from "zod/v4";
|
||||||
import { FileDropzone } from "./FileDropzone";
|
import { FileDropzone } from "./FileDropzone";
|
||||||
|
|
||||||
type CommissionTypeWithRelations = CommissionType & {
|
type CommissionTypeWithRelations = CommissionType & {
|
||||||
@ -37,15 +40,40 @@ type CommissionTypeWithRelations = CommissionType & {
|
|||||||
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
|
customInputs: (CommissionTypeCustomInput & { customInput: CommissionCustomInput })[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type CommissionCustomCardWithRelations = CommissionCustomCard & {
|
||||||
types: CommissionTypeWithRelations[];
|
options: (CommissionCustomCardOption & { option: CommissionOption })[];
|
||||||
|
extras: (CommissionCustomCardExtra & { extra: CommissionExtra })[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CommissionOrderForm({ types }: Props) {
|
type Props = {
|
||||||
|
types: CommissionTypeWithRelations[];
|
||||||
|
customCards: CommissionCustomCardWithRelations[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectedOption = {
|
||||||
|
id: string;
|
||||||
|
optionId: string;
|
||||||
|
option: CommissionOption;
|
||||||
|
price: number | null;
|
||||||
|
pricePercent: number | null;
|
||||||
|
priceRange: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectedExtra = {
|
||||||
|
id: string;
|
||||||
|
extraId: string;
|
||||||
|
extra: CommissionExtra;
|
||||||
|
price: number | null;
|
||||||
|
pricePercent: number | null;
|
||||||
|
priceRange: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CommissionOrderForm({ types, customCards }: Props) {
|
||||||
const form = useForm<z.infer<typeof commissionOrderSchema>>({
|
const form = useForm<z.infer<typeof commissionOrderSchema>>({
|
||||||
resolver: zodResolver(commissionOrderSchema),
|
resolver: zodResolver(commissionOrderSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
typeId: "",
|
typeId: "",
|
||||||
|
customCardId: "",
|
||||||
optionId: "",
|
optionId: "",
|
||||||
extraIds: [],
|
extraIds: [],
|
||||||
customerName: "",
|
customerName: "",
|
||||||
@ -59,19 +87,49 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const typeId = useWatch({ control: form.control, name: "typeId" });
|
const typeId = useWatch({ control: form.control, name: "typeId" });
|
||||||
|
const customCardId = useWatch({ control: form.control, name: "customCardId" });
|
||||||
const optionId = useWatch({ control: form.control, name: "optionId" });
|
const optionId = useWatch({ control: form.control, name: "optionId" });
|
||||||
const extraIds = useWatch({ control: form.control, name: "extraIds" });
|
const extraIds = useWatch({ control: form.control, name: "extraIds" });
|
||||||
|
|
||||||
const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
|
const selectedType = useMemo(() => types.find((t) => t.id === typeId), [types, typeId]);
|
||||||
|
const selectedCustomCard = useMemo(
|
||||||
|
() => customCards.find((c) => c.id === customCardId),
|
||||||
|
[customCards, customCardId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selection = useMemo<{
|
||||||
|
kind: "type" | "custom";
|
||||||
|
name: string;
|
||||||
|
options: SelectedOption[];
|
||||||
|
extras: SelectedExtra[];
|
||||||
|
} | null>(() => {
|
||||||
|
if (selectedCustomCard) {
|
||||||
|
return {
|
||||||
|
kind: "custom",
|
||||||
|
name: selectedCustomCard.name,
|
||||||
|
options: selectedCustomCard.options,
|
||||||
|
extras: selectedCustomCard.extras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (selectedType) {
|
||||||
|
return {
|
||||||
|
kind: "type",
|
||||||
|
name: selectedType.name,
|
||||||
|
options: selectedType.options,
|
||||||
|
extras: selectedType.extras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [selectedCustomCard, selectedType]);
|
||||||
|
|
||||||
const selectedOption = useMemo(
|
const selectedOption = useMemo(
|
||||||
() => selectedType?.options.find((o) => o.optionId === optionId),
|
() => selection?.options.find((o) => o.optionId === optionId),
|
||||||
[selectedType, optionId]
|
[selection, optionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedExtras = useMemo(
|
const selectedExtras = useMemo(
|
||||||
() => selectedType?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
() => selection?.extras.filter((e) => extraIds?.includes(e.extraId)) ?? [],
|
||||||
[selectedType, extraIds]
|
[selection, extraIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [minPrice, maxPrice] = useMemo(() => {
|
const [minPrice, maxPrice] = useMemo(() => {
|
||||||
@ -84,6 +142,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
typeId: values.typeId || null,
|
typeId: values.typeId || null,
|
||||||
|
customCardId: values.customCardId || null,
|
||||||
optionId: values.optionId || null,
|
optionId: values.optionId || null,
|
||||||
extraIds: values.extraIds ?? [],
|
extraIds: values.extraIds ?? [],
|
||||||
customerName: values.customerName,
|
customerName: values.customerName,
|
||||||
@ -100,6 +159,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
typeId: "",
|
typeId: "",
|
||||||
|
customCardId: "",
|
||||||
optionId: "",
|
optionId: "",
|
||||||
extraIds: [],
|
extraIds: [],
|
||||||
customerName: "",
|
customerName: "",
|
||||||
@ -136,7 +196,12 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
key={type.id}
|
key={type.id}
|
||||||
type="button"
|
type="button"
|
||||||
variant={field.value === type.id ? "default" : "outline"}
|
variant={field.value === type.id ? "default" : "outline"}
|
||||||
onClick={() => field.onChange(type.id)}
|
onClick={() => {
|
||||||
|
field.onChange(type.id);
|
||||||
|
form.setValue("customCardId", "");
|
||||||
|
form.setValue("optionId", "");
|
||||||
|
form.setValue("extraIds", []);
|
||||||
|
}}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{type.name}
|
{type.name}
|
||||||
@ -149,7 +214,40 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{selectedType && (
|
{customCards.length > 0 ? (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="customCardId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Custom requests / YCH</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{customCards.map((card) => (
|
||||||
|
<Button
|
||||||
|
key={card.id}
|
||||||
|
type="button"
|
||||||
|
variant={field.value === card.id ? "default" : "outline"}
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(card.id);
|
||||||
|
form.setValue("typeId", "");
|
||||||
|
form.setValue("optionId", "");
|
||||||
|
form.setValue("extraIds", []);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{card.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selection && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -159,7 +257,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
<FormLabel>Base Option</FormLabel>
|
<FormLabel>Base Option</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{selectedType.options.map((opt) => (
|
{selection.options.map((opt) => (
|
||||||
<label key={opt.id} className="flex items-center gap-2">
|
<label key={opt.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@ -186,7 +284,7 @@ export function CommissionOrderForm({ types }: Props) {
|
|||||||
<FormLabel>Extras</FormLabel>
|
<FormLabel>Extras</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{selectedType.extras.map((ext) => (
|
{selection.extras.map((ext) => (
|
||||||
<label key={ext.id} className="flex items-center gap-2">
|
<label key={ext.id} className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
// import { Fraunces } from "next/font/google";
|
|
||||||
import localFont from 'next/font/local';
|
import localFont from 'next/font/local';
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
||||||
// const pacifico = Fraunces({ weight: "700", subsets: ["latin"] });
|
|
||||||
|
|
||||||
const myFont = localFont({
|
const myFont = localFont({
|
||||||
src: './Echotopia-Regular.woff2',
|
src: './Echotopia-Regular.woff2',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||||
import * as React from "react"
|
import type * as React from "react"
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ const links = [
|
|||||||
{ href: "/artworks", label: "Portfolio" },
|
{ href: "/artworks", label: "Portfolio" },
|
||||||
{ href: "/artworks/animalstudies", label: "Animal Studies" },
|
{ href: "/artworks/animalstudies", label: "Animal Studies" },
|
||||||
{ href: "/commissions", label: "Commissions" },
|
{ href: "/commissions", label: "Commissions" },
|
||||||
|
{ href: "/commissions/status", label: "Commission Status" },
|
||||||
{ href: "/tos", label: "Terms of Service" },
|
{ href: "/tos", label: "Terms of Service" },
|
||||||
// { href: "/portfolio/artfight", label: "Artfight" },
|
// { href: "/portfolio/artfight", label: "Artfight" },
|
||||||
// { href: "/portfolio/minis", label: "Miniatures" },
|
// { href: "/portfolio/minis", label: "Miniatures" },
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||||
import { ChevronDownIcon } from "lucide-react"
|
import { ChevronDownIcon } from "lucide-react"
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@ -63,4 +63,5 @@ function AccordionContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input dark:border-input dark:hover:bg-input/60",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@ -14,7 +14,7 @@ function Checkbox({
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input dark:bg-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
className
|
className
|
||||||
|
|||||||
@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import * as z from "zod/v4"
|
import * as z from "zod/v4"
|
||||||
|
|
||||||
export const commissionOrderSchema = z.object({
|
export const commissionOrderSchema = z.object({
|
||||||
typeId: z.string().min(1, "Please select a type"),
|
typeId: z.string().optional(),
|
||||||
|
customCardId: z.string().optional(),
|
||||||
optionId: z.string().min(1, "Please choose a base option"),
|
optionId: z.string().min(1, "Please choose a base option"),
|
||||||
extraIds: z.array(z.string()).optional(),
|
extraIds: z.array(z.string()).optional(),
|
||||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||||
@ -9,4 +10,23 @@ export const commissionOrderSchema = z.object({
|
|||||||
customerEmail: z.email("Invalid email"),
|
customerEmail: z.email("Invalid email"),
|
||||||
customerSocials: z.string().optional(),
|
customerSocials: z.string().optional(),
|
||||||
message: z.string().min(5, "Please describe what you want"),
|
message: z.string().min(5, "Please describe what you want"),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
const hasType = Boolean(data.typeId && data.typeId.length > 0);
|
||||||
|
const hasCustom = Boolean(data.customCardId && data.customCardId.length > 0);
|
||||||
|
|
||||||
|
if (!hasType && !hasCustom) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["typeId"],
|
||||||
|
message: "Please select a commission type or a custom card",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasType && hasCustom) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["typeId"],
|
||||||
|
message: "Choose either a type or a custom card, not both",
|
||||||
|
});
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,7 +10,7 @@ export function calculatePrice(source: PriceSource, base: number): number {
|
|||||||
if (source.priceRange) {
|
if (source.priceRange) {
|
||||||
const parts = source.priceRange.split("–").map(Number)
|
const parts = source.priceRange.split("–").map(Number)
|
||||||
const max = Math.max(...parts)
|
const max = Math.max(...parts)
|
||||||
return isNaN(max) ? 0 : max
|
return Number.isNaN(max) ? 0 : max
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@ -39,8 +39,8 @@ export function calculatePriceRange(
|
|||||||
const min = Number(minStr)
|
const min = Number(minStr)
|
||||||
const max = Number(maxStr)
|
const max = Number(maxStr)
|
||||||
|
|
||||||
if (!isNaN(min)) minExtra += min
|
if (!Number.isNaN(min)) minExtra += min
|
||||||
if (!isNaN(max)) maxExtra += max
|
if (!Number.isNaN(max)) maxExtra += max
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user