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" /> +
{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