feat(web): polish commission flow and default public navigation
This commit is contained in:
1
TODO.md
1
TODO.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user