feat(web): polish commission flow and default public navigation

This commit is contained in:
2026-02-12 22:04:42 +01:00
parent 958f3ad723
commit 47e59d2926
8 changed files with 128 additions and 18 deletions

View File

@@ -325,6 +325,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback. - [2026-02-12] Page editor now supports locale translations in `/pages/:id`; public page rendering uses locale-aware page lookup with base-content fallback.
- [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 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] 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] 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.
## How We Use This File ## How We Use This File

View File

@@ -69,6 +69,13 @@ export default async function PublicCommissionRequestPage({
async function submitCommissionRequestAction(formData: FormData) { async function submitCommissionRequestAction(formData: FormData) {
"use server" "use server"
const budgetMin = readNullableNumber(formData, "budgetMin")
const budgetMax = readNullableNumber(formData, "budgetMax")
if (budgetMin != null && budgetMax != null && budgetMax < budgetMin) {
redirect(buildRedirect(locale, { error: "budget_range_invalid" }))
}
try { try {
await createPublicCommissionRequest({ await createPublicCommissionRequest({
customerName: readInputString(formData, "customerName"), customerName: readInputString(formData, "customerName"),
@@ -77,8 +84,8 @@ export default async function PublicCommissionRequestPage({
customerInstagram: readNullableString(formData, "customerInstagram"), customerInstagram: readNullableString(formData, "customerInstagram"),
title: readInputString(formData, "title"), title: readInputString(formData, "title"),
description: readNullableString(formData, "description"), description: readNullableString(formData, "description"),
budgetMin: readNullableNumber(formData, "budgetMin"), budgetMin,
budgetMax: readNullableNumber(formData, "budgetMax"), budgetMax,
}) })
} catch { } catch {
redirect(buildRedirect(locale, { error: "submission_failed" })) redirect(buildRedirect(locale, { error: "submission_failed" }))
@@ -110,6 +117,11 @@ export default async function PublicCommissionRequestPage({
{t("error")} {t("error")}
</section> </section>
) : null} ) : null}
{error === "budget_range_invalid" ? (
<section className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{t("budgetRangeError")}
</section>
) : null}
<form action={submitCommissionRequestAction} className="space-y-4 rounded-xl border p-6"> <form action={submitCommissionRequestAction} className="space-y-4 rounded-xl border p-6">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -117,6 +129,7 @@ export default async function PublicCommissionRequestPage({
<span className="text-xs text-neutral-600">{t("fields.customerName")}</span> <span className="text-xs text-neutral-600">{t("fields.customerName")}</span>
<input <input
name="customerName" name="customerName"
autoComplete="name"
required required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/> />
@@ -126,6 +139,7 @@ export default async function PublicCommissionRequestPage({
<input <input
name="customerEmail" name="customerEmail"
type="email" type="email"
autoComplete="email"
required required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/> />
@@ -137,6 +151,7 @@ export default async function PublicCommissionRequestPage({
<span className="text-xs text-neutral-600">{t("fields.customerPhone")}</span> <span className="text-xs text-neutral-600">{t("fields.customerPhone")}</span>
<input <input
name="customerPhone" name="customerPhone"
autoComplete="tel"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/> />
</label> </label>

View File

@@ -1,5 +1,5 @@
import { listPublicNavigation } from "@cms/db" import { listPublicNavigation } from "@cms/db"
import { getLocale } from "next-intl/server" import { getLocale, getTranslations } from "next-intl/server"
import { Link } from "@/i18n/navigation" import { Link } from "@/i18n/navigation"
@@ -7,7 +7,33 @@ import { LanguageSwitcher } from "./language-switcher"
export async function PublicSiteHeader() { export async function PublicSiteHeader() {
const locale = await getLocale() const locale = await getLocale()
const navItems = await listPublicNavigation("header", locale) const [navItems, t] = await Promise.all([
listPublicNavigation("header", locale),
getTranslations("Layout.nav"),
])
const fallbackNavItems = [
{
id: "fallback-home",
href: "/",
label: t("home"),
},
{
id: "fallback-portfolio",
href: "/portfolio",
label: t("portfolio"),
},
{
id: "fallback-news",
href: "/news",
label: t("news"),
},
{
id: "fallback-commissions",
href: "/commissions",
label: t("commissions"),
},
]
const resolvedNavItems = navItems.length > 0 ? navItems : fallbackNavItems
return ( return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur"> <header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
@@ -20,15 +46,7 @@ export async function PublicSiteHeader() {
</Link> </Link>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
{navItems.length === 0 ? ( {resolvedNavItems.map((item) => (
<Link
href="/"
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
Home
</Link>
) : (
navItems.map((item) => (
<Link <Link
key={item.id} key={item.id}
href={item.href} href={item.href}
@@ -36,8 +54,7 @@ export async function PublicSiteHeader() {
> >
{item.label} {item.label}
</Link> </Link>
)) ))}
)}
</nav> </nav>
<LanguageSwitcher /> <LanguageSwitcher />

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web", "brand": "CMS Web",
"nav": { "nav": {
"home": "Start", "home": "Start",
"portfolio": "Portfolio",
"news": "News",
"commissions": "Aufträge",
"about": "Über uns", "about": "Über uns",
"contact": "Kontakt" "contact": "Kontakt"
}, },
@@ -49,6 +52,7 @@
"description": "Teile deine Idee und Projektdetails. Wir prüfen die Anfrage und melden uns zeitnah.", "description": "Teile deine Idee und Projektdetails. Wir prüfen die Anfrage und melden uns zeitnah.",
"success": "Deine Auftragsanfrage wurde übermittelt.", "success": "Deine Auftragsanfrage wurde übermittelt.",
"error": "Übermittlung fehlgeschlagen. Bitte prüfe die Eingaben und versuche es erneut.", "error": "Übermittlung fehlgeschlagen. Bitte prüfe die Eingaben und versuche es erneut.",
"budgetRangeError": "Das maximale Budget muss größer oder gleich dem minimalen Budget sein.",
"submit": "Anfrage senden", "submit": "Anfrage senden",
"fields": { "fields": {
"customerName": "Name", "customerName": "Name",

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web", "brand": "CMS Web",
"nav": { "nav": {
"home": "Home", "home": "Home",
"portfolio": "Portfolio",
"news": "News",
"commissions": "Commissions",
"about": "About", "about": "About",
"contact": "Contact" "contact": "Contact"
}, },
@@ -49,6 +52,7 @@
"description": "Share your idea and project details. We will review and reply as soon as possible.", "description": "Share your idea and project details. We will review and reply as soon as possible.",
"success": "Your commission request was submitted.", "success": "Your commission request was submitted.",
"error": "Submission failed. Please review your data and try again.", "error": "Submission failed. Please review your data and try again.",
"budgetRangeError": "Budget max must be greater than or equal to budget min.",
"submit": "Submit request", "submit": "Submit request",
"fields": { "fields": {
"customerName": "Name", "customerName": "Name",

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web", "brand": "CMS Web",
"nav": { "nav": {
"home": "Inicio", "home": "Inicio",
"portfolio": "Portafolio",
"news": "Noticias",
"commissions": "Comisiones",
"about": "Acerca de", "about": "Acerca de",
"contact": "Contacto" "contact": "Contacto"
}, },
@@ -49,6 +52,7 @@
"description": "Comparte tu idea y detalles del proyecto. Revisaremos la solicitud y responderemos pronto.", "description": "Comparte tu idea y detalles del proyecto. Revisaremos la solicitud y responderemos pronto.",
"success": "Tu solicitud de comisión fue enviada.", "success": "Tu solicitud de comisión fue enviada.",
"error": "No se pudo enviar la solicitud. Revisa los datos e inténtalo de nuevo.", "error": "No se pudo enviar la solicitud. Revisa los datos e inténtalo de nuevo.",
"budgetRangeError": "El presupuesto máximo debe ser mayor o igual al mínimo.",
"submit": "Enviar solicitud", "submit": "Enviar solicitud",
"fields": { "fields": {
"customerName": "Nombre", "customerName": "Nombre",

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web", "brand": "CMS Web",
"nav": { "nav": {
"home": "Accueil", "home": "Accueil",
"portfolio": "Portfolio",
"news": "Actualités",
"commissions": "Commissions",
"about": "À propos", "about": "À propos",
"contact": "Contact" "contact": "Contact"
}, },
@@ -49,6 +52,7 @@
"description": "Partagez votre idée et les détails du projet. Nous examinerons la demande et répondrons rapidement.", "description": "Partagez votre idée et les détails du projet. Nous examinerons la demande et répondrons rapidement.",
"success": "Votre demande de commission a été envoyée.", "success": "Votre demande de commission a été envoyée.",
"error": "Échec de l'envoi. Vérifiez les données et réessayez.", "error": "Échec de l'envoi. Vérifiez les données et réessayez.",
"budgetRangeError": "Le budget max doit être supérieur ou égal au budget min.",
"submit": "Envoyer la demande", "submit": "Envoyer la demande",
"fields": { "fields": {
"customerName": "Nom", "customerName": "Nom",

View File

@@ -159,6 +159,67 @@ async function main() {
}) })
} }
const defaultHeaderItems = [
{
label: "Portfolio",
href: "/portfolio",
sortOrder: 1,
pageId: null,
},
{
label: "News",
href: "/news",
sortOrder: 2,
pageId: null,
},
{
label: "Commissions",
href: "/commissions",
sortOrder: 3,
pageId: null,
},
] as const
for (const item of defaultHeaderItems) {
const existingItem = await db.navigationItem.findFirst({
where: {
menuId: primaryMenu.id,
parentId: null,
href: item.href,
},
select: {
id: true,
},
})
if (existingItem) {
await db.navigationItem.update({
where: {
id: existingItem.id,
},
data: {
label: item.label,
sortOrder: item.sortOrder,
isVisible: true,
pageId: item.pageId,
},
})
continue
}
await db.navigationItem.create({
data: {
menuId: primaryMenu.id,
label: item.label,
href: item.href,
pageId: item.pageId,
parentId: null,
sortOrder: item.sortOrder,
isVisible: true,
},
})
}
const existingCustomer = await db.customer.findFirst({ const existingCustomer = await db.customer.findFirst({
where: { where: {
email: "collector@example.com", email: "collector@example.com",