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] 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 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

View File

@@ -69,6 +69,13 @@ export default async function PublicCommissionRequestPage({
async function submitCommissionRequestAction(formData: FormData) {
"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 {
await createPublicCommissionRequest({
customerName: readInputString(formData, "customerName"),
@@ -77,8 +84,8 @@ export default async function PublicCommissionRequestPage({
customerInstagram: readNullableString(formData, "customerInstagram"),
title: readInputString(formData, "title"),
description: readNullableString(formData, "description"),
budgetMin: readNullableNumber(formData, "budgetMin"),
budgetMax: readNullableNumber(formData, "budgetMax"),
budgetMin,
budgetMax,
})
} catch {
redirect(buildRedirect(locale, { error: "submission_failed" }))
@@ -110,6 +117,11 @@ export default async function PublicCommissionRequestPage({
{t("error")}
</section>
) : 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">
<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>
<input
name="customerName"
autoComplete="name"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
@@ -126,6 +139,7 @@ export default async function PublicCommissionRequestPage({
<input
name="customerEmail"
type="email"
autoComplete="email"
required
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>
<input
name="customerPhone"
autoComplete="tel"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>

View File

@@ -1,5 +1,5 @@
import { listPublicNavigation } from "@cms/db"
import { getLocale } from "next-intl/server"
import { getLocale, getTranslations } from "next-intl/server"
import { Link } from "@/i18n/navigation"
@@ -7,7 +7,33 @@ import { LanguageSwitcher } from "./language-switcher"
export async function PublicSiteHeader() {
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 (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
@@ -20,24 +46,15 @@ export async function PublicSiteHeader() {
</Link>
<nav className="flex flex-wrap items-center gap-2">
{navItems.length === 0 ? (
{resolvedNavItems.map((item) => (
<Link
href="/"
key={item.id}
href={item.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
{item.label}
</Link>
) : (
navItems.map((item) => (
<Link
key={item.id}
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
{item.label}
</Link>
))
)}
))}
</nav>
<LanguageSwitcher />

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Start",
"portfolio": "Portfolio",
"news": "News",
"commissions": "Aufträge",
"about": "Über uns",
"contact": "Kontakt"
},
@@ -49,6 +52,7 @@
"description": "Teile deine Idee und Projektdetails. Wir prüfen die Anfrage und melden uns zeitnah.",
"success": "Deine Auftragsanfrage wurde übermittelt.",
"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",
"fields": {
"customerName": "Name",

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Home",
"portfolio": "Portfolio",
"news": "News",
"commissions": "Commissions",
"about": "About",
"contact": "Contact"
},
@@ -49,6 +52,7 @@
"description": "Share your idea and project details. We will review and reply as soon as possible.",
"success": "Your commission request was submitted.",
"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",
"fields": {
"customerName": "Name",

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Inicio",
"portfolio": "Portafolio",
"news": "Noticias",
"commissions": "Comisiones",
"about": "Acerca de",
"contact": "Contacto"
},
@@ -49,6 +52,7 @@
"description": "Comparte tu idea y detalles del proyecto. Revisaremos la solicitud y responderemos pronto.",
"success": "Tu solicitud de comisión fue enviada.",
"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",
"fields": {
"customerName": "Nombre",

View File

@@ -21,6 +21,9 @@
"brand": "CMS Web",
"nav": {
"home": "Accueil",
"portfolio": "Portfolio",
"news": "Actualités",
"commissions": "Commissions",
"about": "À propos",
"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.",
"success": "Votre demande de commission a été envoyée.",
"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",
"fields": {
"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({
where: {
email: "collector@example.com",