feat(web): add public commission request entrypoint
This commit is contained in:
5
TODO.md
5
TODO.md
@@ -173,9 +173,9 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
|
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
|
||||||
- [~] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
|
- [~] [P1] Translation-ready content model for public entities (pages/news/navigation labels)
|
||||||
- [ ] [P2] Artwork views and listing filters
|
- [ ] [P2] Artwork views and listing filters
|
||||||
- [ ] [P1] Commission request submission flow
|
- [~] [P1] Commission request submission flow
|
||||||
- [x] [P1] Header banner render logic and fallbacks
|
- [x] [P1] Header banner render logic and fallbacks
|
||||||
- [ ] [P1] Announcement render slots (homepage + optional global/top banner position)
|
- [x] [P1] Announcement render slots (homepage + optional global/top banner position)
|
||||||
|
|
||||||
### News / Blog (Secondary Track)
|
### News / Blog (Secondary Track)
|
||||||
|
|
||||||
@@ -323,6 +323,7 @@ This file is the single source of truth for roadmap and delivery progress.
|
|||||||
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
|
- [2026-02-12] Completed admin form component coverage for pages/navigation/media using isolated form components and tests.
|
||||||
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
|
- [2026-02-12] Added page translation CRUD baseline (`PageTranslation`) with locale validation (`de/en/es/fr`) and integration coverage for localized read + fallback behavior.
|
||||||
- [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.
|
||||||
|
|
||||||
## How We Use This File
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
202
apps/web/src/app/[locale]/commissions/page.tsx
Normal file
202
apps/web/src/app/[locale]/commissions/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { getPublishedPageBySlugForLocale, listPosts } from "@cms/db"
|
import { getPublishedPageBySlugForLocale, listPosts } from "@cms/db"
|
||||||
import { Button } from "@cms/ui/button"
|
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { PublicAnnouncements } from "@/components/public-announcements"
|
import { PublicAnnouncements } from "@/components/public-announcements"
|
||||||
import { PublicPageView } from "@/components/public-page-view"
|
import { PublicPageView } from "@/components/public-page-view"
|
||||||
|
import { Link } from "@/i18n/navigation"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
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">
|
<section className="space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xl font-medium">{t("latestPosts")}</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
|
"description": "Diese Seite liest Beiträge über das gemeinsame Datenbank-Paket.",
|
||||||
"latestPosts": "Neueste Beiträge",
|
"latestPosts": "Neueste Beiträge",
|
||||||
"explore": "Entdecken",
|
"explore": "Entdecken",
|
||||||
"noExcerpt": "Kein Auszug"
|
"noExcerpt": "Kein Auszug",
|
||||||
|
"requestCommission": "Auftrag anfragen"
|
||||||
},
|
},
|
||||||
"LanguageSwitcher": {
|
"LanguageSwitcher": {
|
||||||
"label": "Sprache",
|
"label": "Sprache",
|
||||||
@@ -41,5 +42,23 @@
|
|||||||
"badge": "Kontakt",
|
"badge": "Kontakt",
|
||||||
"title": "Kontakt",
|
"title": "Kontakt",
|
||||||
"description": "Kontakt- und Auftragsabläufe werden in den nächsten MVP-Schritten eingeführt."
|
"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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "This page reads posts through the shared database package.",
|
"description": "This page reads posts through the shared database package.",
|
||||||
"latestPosts": "Latest posts",
|
"latestPosts": "Latest posts",
|
||||||
"explore": "Explore",
|
"explore": "Explore",
|
||||||
"noExcerpt": "No excerpt"
|
"noExcerpt": "No excerpt",
|
||||||
|
"requestCommission": "Request commission"
|
||||||
},
|
},
|
||||||
"LanguageSwitcher": {
|
"LanguageSwitcher": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
@@ -41,5 +42,23 @@
|
|||||||
"badge": "Contact",
|
"badge": "Contact",
|
||||||
"title": "Contact",
|
"title": "Contact",
|
||||||
"description": "Contact and commission flows will be introduced in upcoming MVP steps."
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
|
"description": "Esta página lee publicaciones a través del paquete compartido de base de datos.",
|
||||||
"latestPosts": "Últimas publicaciones",
|
"latestPosts": "Últimas publicaciones",
|
||||||
"explore": "Explorar",
|
"explore": "Explorar",
|
||||||
"noExcerpt": "Sin extracto"
|
"noExcerpt": "Sin extracto",
|
||||||
|
"requestCommission": "Solicitar comisión"
|
||||||
},
|
},
|
||||||
"LanguageSwitcher": {
|
"LanguageSwitcher": {
|
||||||
"label": "Idioma",
|
"label": "Idioma",
|
||||||
@@ -41,5 +42,23 @@
|
|||||||
"badge": "Contacto",
|
"badge": "Contacto",
|
||||||
"title": "Contacto",
|
"title": "Contacto",
|
||||||
"description": "Los flujos de contacto y comisiones se incorporarán en los siguientes pasos del MVP."
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"description": "Cette page lit les publications via le package base de données partagé.",
|
"description": "Cette page lit les publications via le package base de données partagé.",
|
||||||
"latestPosts": "Dernières publications",
|
"latestPosts": "Dernières publications",
|
||||||
"explore": "Explorer",
|
"explore": "Explorer",
|
||||||
"noExcerpt": "Aucun extrait"
|
"noExcerpt": "Aucun extrait",
|
||||||
|
"requestCommission": "Demander une commission"
|
||||||
},
|
},
|
||||||
"LanguageSwitcher": {
|
"LanguageSwitcher": {
|
||||||
"label": "Langue",
|
"label": "Langue",
|
||||||
@@ -41,5 +42,23 @@
|
|||||||
"badge": "Contact",
|
"badge": "Contact",
|
||||||
"title": "Contact",
|
"title": "Contact",
|
||||||
"description": "Les flux de contact et de commission seront introduits dans les prochaines étapes MVP."
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,3 +59,15 @@ Key files:
|
|||||||
Key files:
|
Key files:
|
||||||
- `apps/admin/src/app/commissions/page.tsx`
|
- `apps/admin/src/app/commissions/page.tsx`
|
||||||
- `packages/db/src/commissions.ts`
|
- `packages/db/src/commissions.ts`
|
||||||
|
|
||||||
|
## 6. Public Commission Request Submission
|
||||||
|
|
||||||
|
1. Visitor opens `/{locale}/commissions` and submits request form.
|
||||||
|
2. Server action validates input through shared schema.
|
||||||
|
3. Existing customer is reused by email (and marked recurring) or a new customer is created.
|
||||||
|
4. A new commission is created in `new` status and linked to the resolved customer.
|
||||||
|
|
||||||
|
Key files:
|
||||||
|
- `apps/web/src/app/[locale]/commissions/page.tsx`
|
||||||
|
- `packages/content/src/commissions.ts`
|
||||||
|
- `packages/db/src/commissions.ts`
|
||||||
|
|||||||
@@ -29,6 +29,26 @@ export const createCommissionInputSchema = z.object({
|
|||||||
dueAt: z.date().nullable().optional(),
|
dueAt: z.date().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createPublicCommissionRequestInputSchema = z
|
||||||
|
.object({
|
||||||
|
customerName: z.string().min(1).max(180),
|
||||||
|
customerEmail: z.string().email().max(320),
|
||||||
|
customerPhone: z.string().max(80).nullable().optional(),
|
||||||
|
customerInstagram: z.string().max(120).nullable().optional(),
|
||||||
|
title: z.string().min(1).max(180),
|
||||||
|
description: z.string().max(4000).nullable().optional(),
|
||||||
|
budgetMin: z.number().nonnegative().nullable().optional(),
|
||||||
|
budgetMax: z.number().nonnegative().nullable().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
value.budgetMin == null || value.budgetMax == null || value.budgetMax >= value.budgetMin,
|
||||||
|
{
|
||||||
|
message: "budgetMax must be greater than or equal to budgetMin.",
|
||||||
|
path: ["budgetMax"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
export const updateCommissionStatusInputSchema = z.object({
|
export const updateCommissionStatusInputSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
status: commissionStatusSchema,
|
status: commissionStatusSchema,
|
||||||
@@ -37,4 +57,7 @@ export const updateCommissionStatusInputSchema = z.object({
|
|||||||
export type CommissionStatus = z.infer<typeof commissionStatusSchema>
|
export type CommissionStatus = z.infer<typeof commissionStatusSchema>
|
||||||
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>
|
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>
|
||||||
export type CreateCommissionInput = z.infer<typeof createCommissionInputSchema>
|
export type CreateCommissionInput = z.infer<typeof createCommissionInputSchema>
|
||||||
|
export type CreatePublicCommissionRequestInput = z.infer<
|
||||||
|
typeof createPublicCommissionRequestInputSchema
|
||||||
|
>
|
||||||
export type UpdateCommissionStatusInput = z.infer<typeof updateCommissionStatusInputSchema>
|
export type UpdateCommissionStatusInput = z.infer<typeof updateCommissionStatusInputSchema>
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
|||||||
|
|
||||||
const { mockDb } = vi.hoisted(() => ({
|
const { mockDb } = vi.hoisted(() => ({
|
||||||
mockDb: {
|
mockDb: {
|
||||||
|
$transaction: vi.fn(),
|
||||||
customer: {
|
customer: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
commission: {
|
commission: {
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
@@ -18,17 +21,29 @@ vi.mock("./client", () => ({
|
|||||||
db: mockDb,
|
db: mockDb,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { createCommission, createCustomer, updateCommissionStatus } from "./commissions"
|
import {
|
||||||
|
createCommission,
|
||||||
|
createCustomer,
|
||||||
|
createPublicCommissionRequest,
|
||||||
|
updateCommissionStatus,
|
||||||
|
} from "./commissions"
|
||||||
|
|
||||||
describe("commissions service", () => {
|
describe("commissions service", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const value of Object.values(mockDb)) {
|
for (const value of Object.values(mockDb)) {
|
||||||
|
if (typeof value === "function") {
|
||||||
|
value.mockReset()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
for (const fn of Object.values(value)) {
|
for (const fn of Object.values(value)) {
|
||||||
if (typeof fn === "function") {
|
if (typeof fn === "function") {
|
||||||
fn.mockReset()
|
fn.mockReset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mockDb.$transaction.mockImplementation(async (callback) => callback(mockDb))
|
||||||
})
|
})
|
||||||
|
|
||||||
it("creates customer and commission payloads", async () => {
|
it("creates customer and commission payloads", async () => {
|
||||||
@@ -51,6 +66,37 @@ describe("commissions service", () => {
|
|||||||
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
|
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("creates a public commission request with customer upsert behavior", async () => {
|
||||||
|
mockDb.customer.findFirst.mockResolvedValue({
|
||||||
|
id: "customer-existing",
|
||||||
|
phone: null,
|
||||||
|
instagram: null,
|
||||||
|
})
|
||||||
|
mockDb.customer.update.mockResolvedValue({
|
||||||
|
id: "customer-existing",
|
||||||
|
})
|
||||||
|
mockDb.commission.create.mockResolvedValue({
|
||||||
|
id: "commission-2",
|
||||||
|
})
|
||||||
|
|
||||||
|
await createPublicCommissionRequest({
|
||||||
|
customerName: "Grace Hopper",
|
||||||
|
customerEmail: "GRACE@EXAMPLE.COM",
|
||||||
|
customerPhone: "12345",
|
||||||
|
title: "Landscape commission",
|
||||||
|
description: "Oil painting request",
|
||||||
|
budgetMin: 500,
|
||||||
|
budgetMax: 900,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockDb.customer.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { email: "grace@example.com" },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
})
|
||||||
|
expect(mockDb.customer.update).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it("updates commission status", async () => {
|
it("updates commission status", async () => {
|
||||||
mockDb.commission.update.mockResolvedValue({ id: "commission-1", status: "done" })
|
mockDb.commission.update.mockResolvedValue({ id: "commission-1", status: "done" })
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
commissionStatusSchema,
|
commissionStatusSchema,
|
||||||
createCommissionInputSchema,
|
createCommissionInputSchema,
|
||||||
createCustomerInputSchema,
|
createCustomerInputSchema,
|
||||||
|
createPublicCommissionRequestInputSchema,
|
||||||
updateCommissionStatusInputSchema,
|
updateCommissionStatusInputSchema,
|
||||||
} from "@cms/content"
|
} from "@cms/content"
|
||||||
|
|
||||||
@@ -56,6 +57,63 @@ export async function createCommission(input: unknown) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createPublicCommissionRequest(input: unknown) {
|
||||||
|
const payload = createPublicCommissionRequestInputSchema.parse(input)
|
||||||
|
const normalizedEmail = payload.customerEmail.trim().toLowerCase()
|
||||||
|
|
||||||
|
return db.$transaction(async (tx) => {
|
||||||
|
const existingCustomer = await tx.customer.findFirst({
|
||||||
|
where: {
|
||||||
|
email: normalizedEmail,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const customer = existingCustomer
|
||||||
|
? await tx.customer.update({
|
||||||
|
where: { id: existingCustomer.id },
|
||||||
|
data: {
|
||||||
|
name: payload.customerName,
|
||||||
|
phone: payload.customerPhone ?? existingCustomer.phone,
|
||||||
|
instagram: payload.customerInstagram ?? existingCustomer.instagram,
|
||||||
|
isRecurring: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: await tx.customer.create({
|
||||||
|
data: {
|
||||||
|
name: payload.customerName,
|
||||||
|
email: normalizedEmail,
|
||||||
|
phone: payload.customerPhone,
|
||||||
|
instagram: payload.customerInstagram,
|
||||||
|
isRecurring: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return tx.commission.create({
|
||||||
|
data: {
|
||||||
|
title: payload.title,
|
||||||
|
description: payload.description,
|
||||||
|
status: "new",
|
||||||
|
customerId: customer.id,
|
||||||
|
budgetMin: payload.budgetMin,
|
||||||
|
budgetMax: payload.budgetMax,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
customer: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
isRecurring: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCommissionStatus(input: unknown) {
|
export async function updateCommissionStatus(input: unknown) {
|
||||||
const payload = updateCommissionStatusInputSchema.parse(input)
|
const payload = updateCommissionStatusInputSchema.parse(input)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
commissionKanbanOrder,
|
commissionKanbanOrder,
|
||||||
createCommission,
|
createCommission,
|
||||||
createCustomer,
|
createCustomer,
|
||||||
|
createPublicCommissionRequest,
|
||||||
listCommissions,
|
listCommissions,
|
||||||
listCustomers,
|
listCustomers,
|
||||||
updateCommissionStatus,
|
updateCommissionStatus,
|
||||||
|
|||||||
Reference in New Issue
Block a user