diff --git a/TODO.md b/TODO.md index b6c0c77..ee99e56 100644 --- a/TODO.md +++ b/TODO.md @@ -142,7 +142,7 @@ This file is the single source of truth for roadmap and delivery progress. - [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif) - [x] [P1] Media enrichment metadata (alt text, copyright, author, source, tags, licensing, usage context) - [x] [P1] Portfolio grouping primitives (galleries, albums, categories, tags) with ordering/visibility controls -- [ ] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility) +- [x] [P1] Artwork refinement fields (medium, dimensions, year, framing, availability, price visibility) - [ ] [P1] Artwork rendition management (thumbnail, card, full, retina/custom sizes) - [ ] [P1] Type-specific processing presets (artwork/banner/promo/video/gif) with validation rules - [ ] [P1] Users management (invite, roles, status) @@ -362,6 +362,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Public rendering integration advanced with locale-aware navigation/news translations and a new public commission request entry route (`/[locale]/commissions`) that creates/reuses customer records and opens a `new` commission. - [2026-02-12] Public portfolio baseline added with `/{locale}/portfolio` and `/{locale}/portfolio/{slug}`, including published-artwork filters (gallery/album/category/tag), rendition image streaming via web `/api/media/file/:id`, and media-aware artwork detail rendering. - [2026-02-12] Portfolio grouping controls completed in admin `/portfolio`: galleries/albums/categories/tags now support visibility and sort-order management (create/update/delete), and public tag filters now respect visibility. +- [2026-02-12] Artwork refinement baseline completed: admin `/portfolio` now captures/edits medium, dimensions, year, framing, availability, publish state, and optional price visibility (`priceAmountCents` + `priceCurrency`), with public artwork detail rendering visible prices only. - [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries. - [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path). - [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`. diff --git a/apps/admin/src/app/portfolio/page.tsx b/apps/admin/src/app/portfolio/page.tsx index 8e5f838..5e925d8 100644 --- a/apps/admin/src/app/portfolio/page.tsx +++ b/apps/admin/src/app/portfolio/page.tsx @@ -10,6 +10,7 @@ import { listArtworks, listMediaAssets, listMediaFoundationGroups, + updateArtwork, updateGrouping, } from "@cms/db" import { Button } from "@cms/ui/button" @@ -45,6 +46,15 @@ function readNonNegativeInt(formData: FormData, key: string): number { return Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0 } +function readOptionalNonNegativeInt(formData: FormData, key: string): number | undefined { + const raw = readField(formData, key) + if (!raw) { + return undefined + } + const value = Number(raw) + return Number.isFinite(value) && value >= 0 ? Math.floor(value) : undefined +} + function readBooleanField(formData: FormData, key: string): boolean { return formData.get(key) === "on" || readField(formData, key) === "true" } @@ -106,6 +116,15 @@ async function createArtworkAction(formData: FormData) { dimensions: readOptionalField(formData, "dimensions"), framing: readOptionalField(formData, "framing"), availability: readOptionalField(formData, "availability"), + priceAmountCents: (() => { + const raw = readField(formData, "priceAmount") + return raw ? Math.round(Number(raw) * 100) : undefined + })(), + priceCurrency: (() => { + const raw = readField(formData, "priceCurrency").toUpperCase() + return raw.length === 3 ? raw : undefined + })(), + isPriceVisible: readBooleanField(formData, "isPriceVisible"), year: (() => { const raw = readField(formData, "year") return raw ? Number(raw) : undefined @@ -119,6 +138,41 @@ async function createArtworkAction(formData: FormData) { redirectWithState({ notice: "Artwork created." }) } +async function updateArtworkAction(formData: FormData) { + "use server" + + await requireWritePermission() + + try { + await updateArtwork({ + id: readField(formData, "artworkId"), + medium: readOptionalNullableField(formData, "medium"), + dimensions: readOptionalNullableField(formData, "dimensions"), + year: (() => { + const raw = readField(formData, "year") + return raw ? Number(raw) : null + })(), + framing: readOptionalNullableField(formData, "framing"), + availability: readOptionalNullableField(formData, "availability"), + priceAmountCents: (() => { + const value = readOptionalNonNegativeInt(formData, "priceAmountCents") + return value ?? null + })(), + priceCurrency: (() => { + const raw = readField(formData, "priceCurrency").toUpperCase() + return raw.length === 3 ? raw : null + })(), + isPriceVisible: readBooleanField(formData, "isPriceVisible"), + isPublished: readBooleanField(formData, "isPublished"), + }) + } catch { + redirectWithState({ error: "Failed to update artwork refinement fields." }) + } + + revalidatePath("/portfolio") + redirectWithState({ notice: "Artwork refinement updated." }) +} + async function createGroupAction(formData: FormData) { "use server" @@ -357,6 +411,26 @@ export default async function PortfolioPage({ placeholder="Availability" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" /> +
+ + + +
@@ -609,14 +683,16 @@ export default async function PortfolioPage({ Title Slug Published + Refinement Renditions Groups + Actions {artworks.length === 0 ? ( - + No artworks yet. Add creation flows after media upload pipeline lands. @@ -626,11 +702,102 @@ export default async function PortfolioPage({ {artwork.title} {artwork.slug} {artwork.isPublished ? "yes" : "no"} + + {artwork.medium ? `medium: ${artwork.medium}` : "medium: -"} +
+ {artwork.dimensions ? `dimensions: ${artwork.dimensions}` : "dimensions: -"} +
+ {artwork.year ? `year: ${artwork.year}` : "year: -"} +
+ {artwork.framing ? `framing: ${artwork.framing}` : "framing: -"} +
+ {artwork.availability + ? `availability: ${artwork.availability}` + : "availability: -"} +
+ {artwork.priceAmountCents && artwork.priceCurrency + ? `price: ${(artwork.priceAmountCents / 100).toFixed(2)} ${artwork.priceCurrency} (${artwork.isPriceVisible ? "visible" : "hidden"})` + : "price: -"} + {artwork.renditions.length} g:{artwork.galleryLinks.length} a:{artwork.albumLinks.length} c: {artwork.categoryLinks.length} t:{artwork.tagLinks.length} + +
+ + + +
+ + +
+ +
+ + +
+
+ + +
+ +
+ )) )} diff --git a/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx b/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx index e773163..f47873b 100644 --- a/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx +++ b/apps/web/src/app/[locale]/portfolio/[slug]/page.tsx @@ -17,6 +17,17 @@ function formatLabelList(values: string[]) { return values.join(", ") } +function formatArtworkPrice(priceAmountCents: number | null, priceCurrency: string | null) { + if (!priceAmountCents || !priceCurrency) { + return "-" + } + + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: priceCurrency, + }).format(priceAmountCents / 100) +} + export default async function PublicArtworkPage({ params }: PublicArtworkPageProps) { const [{ slug }, t] = await Promise.all([params, getTranslations("Portfolio")]) const artwork = await getPublishedArtworkBySlug(slug) @@ -78,6 +89,12 @@ export default async function PublicArtworkPage({ params }: PublicArtworkPagePro

{t("fields.availability")}: {artwork.availability || "-"}

+

+ {t("fields.price")}:{" "} + {artwork.isPriceVisible + ? formatArtworkPrice(artwork.priceAmountCents, artwork.priceCurrency) + : "-"} +

diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 1f8c217..2cf4ad0 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -84,6 +84,7 @@ "dimensions": "Abmessungen", "year": "Jahr", "availability": "Verfügbarkeit", + "price": "Preis", "galleries": "Galerien", "albums": "Alben", "categories": "Kategorien", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 097b1f4..ed5eb9c 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -84,6 +84,7 @@ "dimensions": "Dimensions", "year": "Year", "availability": "Availability", + "price": "Price", "galleries": "Galleries", "albums": "Albums", "categories": "Categories", diff --git a/apps/web/src/messages/es.json b/apps/web/src/messages/es.json index 48afc05..490aeb6 100644 --- a/apps/web/src/messages/es.json +++ b/apps/web/src/messages/es.json @@ -84,6 +84,7 @@ "dimensions": "Dimensiones", "year": "Año", "availability": "Disponibilidad", + "price": "Precio", "galleries": "Galerías", "albums": "Álbumes", "categories": "Categorías", diff --git a/apps/web/src/messages/fr.json b/apps/web/src/messages/fr.json index c2be966..225ba7e 100644 --- a/apps/web/src/messages/fr.json +++ b/apps/web/src/messages/fr.json @@ -84,6 +84,7 @@ "dimensions": "Dimensions", "year": "Année", "availability": "Disponibilité", + "price": "Prix", "galleries": "Galeries", "albums": "Albums", "categories": "Catégories", diff --git a/packages/content/src/media.ts b/packages/content/src/media.ts index f86e2a0..f2c9054 100644 --- a/packages/content/src/media.ts +++ b/packages/content/src/media.ts @@ -65,6 +65,25 @@ export const createArtworkInputSchema = z.object({ year: z.number().int().min(1000).max(9999).optional(), framing: z.string().max(180).optional(), availability: z.string().max(180).optional(), + priceAmountCents: z.number().int().min(0).optional(), + priceCurrency: z.string().min(3).max(3).optional(), + isPriceVisible: z.boolean().optional(), +}) + +export const updateArtworkInputSchema = z.object({ + id: z.string().uuid(), + title: z.string().min(1).max(180).optional(), + slug: z.string().min(1).max(180).optional(), + description: z.string().max(5000).nullable().optional(), + medium: z.string().max(180).nullable().optional(), + dimensions: z.string().max(180).nullable().optional(), + year: z.number().int().min(1000).max(9999).nullable().optional(), + framing: z.string().max(180).nullable().optional(), + availability: z.string().max(180).nullable().optional(), + priceAmountCents: z.number().int().min(0).nullable().optional(), + priceCurrency: z.string().min(3).max(3).nullable().optional(), + isPriceVisible: z.boolean().optional(), + isPublished: z.boolean().optional(), }) export const createGroupingInputSchema = z.object({ @@ -110,6 +129,7 @@ export type ArtworkRenditionSlot = z.infer export type CreateMediaAssetInput = z.infer export type UpdateMediaAssetInput = z.infer export type CreateArtworkInput = z.infer +export type UpdateArtworkInput = z.infer export type CreateGroupingInput = z.infer export type UpdateGroupingInput = z.infer export type DeleteGroupingInput = z.infer diff --git a/packages/db/prisma/migrations/20260213004500_artwork_price_refinement/migration.sql b/packages/db/prisma/migrations/20260213004500_artwork_price_refinement/migration.sql new file mode 100644 index 0000000..c8c5cf6 --- /dev/null +++ b/packages/db/prisma/migrations/20260213004500_artwork_price_refinement/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "Artwork" +ADD COLUMN "priceAmountCents" INTEGER, +ADD COLUMN "priceCurrency" TEXT, +ADD COLUMN "isPriceVisible" BOOLEAN NOT NULL DEFAULT false; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 248e00e..8e89ca5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -153,6 +153,9 @@ model Artwork { year Int? framing String? availability String? + priceAmountCents Int? + priceCurrency String? + isPriceVisible Boolean @default(false) isPublished Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index d499476..ff6f157 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -35,6 +35,7 @@ export { listMediaFoundationGroups, listPublishedArtworks, listPublishedPortfolioGroups, + updateArtwork, updateGrouping, updateMediaAsset, } from "./media-foundation" diff --git a/packages/db/src/media-foundation.ts b/packages/db/src/media-foundation.ts index be5f867..a66228d 100644 --- a/packages/db/src/media-foundation.ts +++ b/packages/db/src/media-foundation.ts @@ -5,6 +5,7 @@ import { createMediaAssetInputSchema, deleteGroupingInputSchema, linkArtworkGroupingInputSchema, + updateArtworkInputSchema, updateGroupingInputSchema, updateMediaAssetInputSchema, } from "@cms/content" @@ -148,6 +149,16 @@ export async function createArtwork(input: unknown) { }) } +export async function updateArtwork(input: unknown) { + const payload = updateArtworkInputSchema.parse(input) + const { id, ...data } = payload + + return db.artwork.update({ + where: { id }, + data, + }) +} + export async function createGallery(input: unknown) { const payload = createGroupingInputSchema.parse(input)