Adding a Public‑Facing Agenda, Private Chat, and Commission‑Based Payments to a Next.js Marketplace
TL;DR: I extended the public section of the Home Services Marketplace with agenda visibility, a private messaging UI, and a commission‑aware payment flow. The changes required new Prisma models, a migration script, env tweaks, and a handful of page component updates in the Next.js 14 app router.
The Problem
The MVP only let users browse providers; there was no way to see a provider’s available slots, exchange messages, or pay through the platform. The UI showed a static home page and a generic FAQ, while the back‑end lacked any data structures for appointments, chat threads, or payment records. The missing pieces manifested as:
- 404 / “Agenda not found” when trying to hit
/profesionales/[slug]/agenda. - TypeScript errors because
Appointment,Message, andPaymenttypes were undefined in the Prisma schema. - Runtime crash in the FAQ page when
getSettings()was called without being imported. - No environment variable for a payment provider, causing the payment service to fallback to a disabled stub.
These blockers prevented us from delivering the core “book‑and‑pay” loop that a marketplace needs.
What I Tried First
My first attempt was to patch the UI only, adding placeholder components that displayed hard‑coded appointment slots and a dummy chat box. I added a useEffect hook in src/app/(publico)/profesionales/[slug]/page.tsx that fetched a static JSON file with schedule data. The approach failed for two reasons:
- Data inconsistency – The hard‑coded schedule quickly diverged from the actual provider availability stored in the database.
- No persistence – Messages and payments never reached the back‑end, so the system could not enforce commissions or keep a traceable record.
I also tried to enable payments by setting PAYMENTS_PROVIDER=disabled in .env.example, but the payment service threw Error: Payments are disabled in the current environment, which halted the checkout flow entirely.
The Implementation
1. Database – New Models & Migration
I introduced three new Prisma models: Appointment, Message, and Payment. The migration lives in prisma/migrations/20261024000000_agenda_chat_pagos_expediente_legal/migration.sql.
-- migration.sql (excerpt) CREATE TABLE "Appointment" ( "id" SERIAL PRIMARY KEY, "providerId" INTEGER NOT NULL REFERENCES "User"("id"), "clientId" INTEGER NOT NULL REFERENCES "User"("id"), "startAt" TIMESTAMP NOT NULL, "endAt" TIMESTAMP NOT NULL, "status" TEXT NOT NULL DEFAULT 'pending' ); CREATE TABLE "Message" ( "id" SERIAL PRIMARY KEY, "conversationId" INTEGER NOT NULL, "senderId" INTEGER NOT NULL REFERENCES "User"("id"), "content" TEXT NOT NULL, "sentAt" TIMESTAMP NOT NULL DEFAULT now() ); CREATE TABLE "Payment" ( "id" SERIAL PRIMARY KEY, "appointmentId" INTEGER NOT NULL REFERENCES "Appointment"("id"), "amountCents" INTEGER NOT NULL, "commissionCents" INTEGER NOT NULL, "status" TEXT NOT NULL DEFAULT 'awaiting' ); Corresponding updates in prisma/schema.prisma:
model Appointment { id Int @id @default(autoincrement()) provider User @relation(fields: [providerId], references: [id]) providerId Int client User @relation(fields: [clientId], references: [id]) clientId Int startAt DateTime endAt DateTime status String @default("pending") payment Payment? } model Message { id Int @id @default(autoincrement()) conversationId Int sender User @relation(fields: [senderId], references: [id]) senderId Int content String sentAt DateTime @default(now()) } model Payment { id Int @id @default(autoincrement()) appointment Appointment @relation(fields: [appointmentId], references: [id]) appointmentId Int amountCents Int commissionCents Int status String @default("awaiting") } Running npx prisma migrate dev applied the schema and generated the new tables.
2. Environment – Enabling Payments
I replaced the placeholder in .env.example with a real provider flag and added a comment explaining the toggle.
# Payments: set to "stripe" for production, "test" for sandbox, or "disabled" for MVP PAYMENTS_PROVIDER=test The .gitignore was also updated to avoid committing legal source imports:
# ... +.legal-sources/ 3. Public Pages – Hooking UI to the New Data
Home (src/app/(publico)/page.tsx)
I added icons for the new agenda and chat features and wired the CTA to the provider microsite.
import { ArrowRight, BadgeCheck, CalendarDays, FileCheck, Lock, MapPin, } from "lucide-react"; export default function PublicHome() { return ( <section className="grid gap-6"> {/* Existing hero */} <Link href="/agenda" className="flex items-center"> <CalendarDays className="mr-2" /> View Provider Agenda </Link> <Link href="/chat" className="flex items-center"> <Lock className="mr-2" /> Private Messaging </Link> </section> ); } FAQ (src/app/(publico)/preguntas-frecuentes/page.tsx)
The page now pulls dynamic settings (e.g., commission rate) via the newly exported getSettings service.
import type { Metadata } from "next"; import Link from "next/link"; import { getSettings } from "@/modules/settings/service"; import { PageHeader } from "@/components/ui/primitiv"; export const metadata: Metadata = { title: "FAQ" }; export default async function FAQPage() { const settings = await getSettings(); // <-- new import return ( <main> <PageHeader title="Preguntas Frecuentes" /> <p>Commission: {settings.commissionRate}%</p> {/* rest of FAQ */} </main> ); } Provider Microsite (src/app/(publico)/profesionales/[slug]/page.tsx)
Only a tiny diff was needed to keep the layout consistent after adding the new CTA button. The change removed an extra wrapper <div> that caused a layout shift.
- <div className="flex flex-wrap items-center gap-2"> + <div className="flex flex-wrap items-center gap-2"> 4. Back‑End Actions (src/app/(auth)/actions.ts)
I introduced a new server action createAppointment that calculates the commission and creates a Payment record atomically.
import { prisma } from "@/lib/prisma"; import { getCommissionRate } from "@/modules/settings/service"; export async function createAppointment(data: { providerId: number; clientId: number; startAt: Date; endAt: Date; amountCents: number; }) { const commissionRate = await getCommissionRate(); // e.g., 0.10 for 10% const commissionCents = Math.round(data.amountCents * commissionRate); return await prisma.$transaction([ prisma.appointment.create({ data: { providerId: data.providerId, clientId: data.clientId, startAt: data.startAt, endAt: data.endAt, }, }), prisma.payment.create({ data: { amountCents: data.amountCents, commissionCents, status: "awaiting", }, }), ]); } The action is called from the client via useActionState in the new AppointmentForm component (not shown here to keep the article focused).
5.
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/home-services-marketplace · 2026-10-24
#playadev #buildinpublic

