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
Top comments (1)
Nice to see the book-and-pay loop come together. One thing worth deciding early is when the provider actually gets paid, because releasing funds at booking leaves you holding every refund and dispute. We build WP Sell Services, a WordPress marketplace plugin, and holding funds until the job is marked complete is the setting owners ask us about most.