feat(web): add public commission request entrypoint

This commit is contained in:
2026-02-12 21:35:34 +01:00
parent dc0a41a5ae
commit 1fddb6d858
12 changed files with 441 additions and 9 deletions

View File

@@ -0,0 +1,202 @@
import { createPublicCommissionRequest } from "@cms/db"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
type PublicCommissionRequestPageProps = {
params: Promise<{ locale: string }>
searchParams: Promise<SearchParamsInput>
}
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | null {
const value = readInputString(formData, field)
return value.length > 0 ? value : null
}
function readNullableNumber(formData: FormData, field: string): number | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = Number.parseFloat(value)
return Number.isFinite(parsed) ? parsed : null
}
function buildRedirect(locale: string, params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const serialized = query.toString()
return serialized ? `/${locale}/commissions?${serialized}` : `/${locale}/commissions`
}
export default async function PublicCommissionRequestPage({
params,
searchParams,
}: PublicCommissionRequestPageProps) {
const { locale } = await params
const [resolvedSearchParams, t] = await Promise.all([
searchParams,
getTranslations("CommissionRequest"),
])
async function submitCommissionRequestAction(formData: FormData) {
"use server"
try {
await createPublicCommissionRequest({
customerName: readInputString(formData, "customerName"),
customerEmail: readInputString(formData, "customerEmail"),
customerPhone: readNullableString(formData, "customerPhone"),
customerInstagram: readNullableString(formData, "customerInstagram"),
title: readInputString(formData, "title"),
description: readNullableString(formData, "description"),
budgetMin: readNullableNumber(formData, "budgetMin"),
budgetMax: readNullableNumber(formData, "budgetMax"),
})
} catch {
redirect(buildRedirect(locale, { error: "submission_failed" }))
}
revalidatePath(`/${locale}/commissions`)
redirect(buildRedirect(locale, { notice: "submitted" }))
}
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<section className="mx-auto w-full max-w-3xl space-y-6 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
{notice === "submitted" ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{t("success")}
</section>
) : null}
{error === "submission_failed" ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{t("error")}
</section>
) : null}
<form action={submitCommissionRequestAction} className="space-y-4 rounded-xl border p-6">
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerName")}</span>
<input
name="customerName"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerEmail")}</span>
<input
name="customerEmail"
type="email"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerPhone")}</span>
<input
name="customerPhone"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.customerInstagram")}</span>
<input
name="customerInstagram"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.title")}</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.description")}</span>
<textarea
name="description"
rows={6}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.budgetMin")}</span>
<input
name="budgetMin"
type="number"
min="0"
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">{t("fields.budgetMax")}</span>
<input
name="budgetMax"
type="number"
min="0"
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<button
type="submit"
className="rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700"
>
{t("submit")}
</button>
</form>
</section>
)
}

View File

@@ -1,8 +1,8 @@
import { getPublishedPageBySlugForLocale, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements"
import { PublicPageView } from "@/components/public-page-view"
import { Link } from "@/i18n/navigation"
export const dynamic = "force-dynamic"
@@ -34,7 +34,20 @@ export default async function HomePage({ params }: HomePageProps) {
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
<div className="flex items-center justify-between">
<h3 className="text-xl font-medium">{t("latestPosts")}</h3>
<Button variant="secondary">{t("explore")}</Button>
<div className="flex items-center gap-2">
<Link
href="/news"
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-sm font-medium text-neutral-900 transition-colors hover:bg-neutral-200"
>
{t("explore")}
</Link>
<Link
href="/commissions"
className="inline-flex h-10 items-center justify-center rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
>
{t("requestCommission")}
</Link>
</div>
</div>
<ul className="space-y-3">

View File

@@ -5,7 +5,8 @@
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
"latestPosts": "Neueste Beiträge",
"explore": "Entdecken",
"noExcerpt": "Kein Auszug"
"noExcerpt": "Kein Auszug",
"requestCommission": "Auftrag anfragen"
},
"LanguageSwitcher": {
"label": "Sprache",
@@ -41,5 +42,23 @@
"badge": "Kontakt",
"title": "Kontakt",
"description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt."
},
"CommissionRequest": {
"badge": "Aufträge",
"title": "Auftragsanfrage",
"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.",
"submit": "Anfrage senden",
"fields": {
"customerName": "Name",
"customerEmail": "E-Mail",
"customerPhone": "Telefon",
"customerInstagram": "Instagram",
"title": "Projekttitel",
"description": "Projektdetails",
"budgetMin": "Budget min.",
"budgetMax": "Budget max."
}
}
}

View File

@@ -5,7 +5,8 @@
"description": "This page reads posts through the shared database package.",
"latestPosts": "Latest posts",
"explore": "Explore",
"noExcerpt": "No excerpt"
"noExcerpt": "No excerpt",
"requestCommission": "Request commission"
},
"LanguageSwitcher": {
"label": "Language",
@@ -41,5 +42,23 @@
"badge": "Contact",
"title": "Contact",
"description": "Contact and commission flows will be introduced in upcoming MVP steps."
},
"CommissionRequest": {
"badge": "Commissions",
"title": "Commission request",
"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.",
"submit": "Submit request",
"fields": {
"customerName": "Name",
"customerEmail": "Email",
"customerPhone": "Phone",
"customerInstagram": "Instagram",
"title": "Project title",
"description": "Project details",
"budgetMin": "Budget min",
"budgetMax": "Budget max"
}
}
}

View File

@@ -5,7 +5,8 @@
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
"latestPosts": "Últimas publicaciones",
"explore": "Explorar",
"noExcerpt": "Sin extracto"
"noExcerpt": "Sin extracto",
"requestCommission": "Solicitar comisión"
},
"LanguageSwitcher": {
"label": "Idioma",
@@ -41,5 +42,23 @@
"badge": "Contacto",
"title": "Contacto",
"description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP."
},
"CommissionRequest": {
"badge": "Comisiones",
"title": "Solicitud de comisión",
"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.",
"submit": "Enviar solicitud",
"fields": {
"customerName": "Nombre",
"customerEmail": "Correo electrónico",
"customerPhone": "Teléfono",
"customerInstagram": "Instagram",
"title": "Título del proyecto",
"description": "Detalles del proyecto",
"budgetMin": "Presupuesto mínimo",
"budgetMax": "Presupuesto máximo"
}
}
}

View File

@@ -5,7 +5,8 @@
"description": "Cette page lit les publications via le package base de données partagé.",
"latestPosts": "Dernières publications",
"explore": "Explorer",
"noExcerpt": "Aucun extrait"
"noExcerpt": "Aucun extrait",
"requestCommission": "Demander une commission"
},
"LanguageSwitcher": {
"label": "Langue",
@@ -41,5 +42,23 @@
"badge": "Contact",
"title": "Contact",
"description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP."
},
"CommissionRequest": {
"badge": "Commissions",
"title": "Demande de commission",
"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.",
"submit": "Envoyer la demande",
"fields": {
"customerName": "Nom",
"customerEmail": "E-mail",
"customerPhone": "Téléphone",
"customerInstagram": "Instagram",
"title": "Titre du projet",
"description": "Détails du projet",
"budgetMin": "Budget min",
"budgetMax": "Budget max"
}
}
}