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, and Payment types 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:

  1. Data inconsistency – The hard‑coded schedule quickly diverged from the actual provider availability stored in the database.
  2. 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