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 (0)