DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding a Public‑Facing Agenda, Private Chat, and Commission‑Based Payments to a Next.js Marketplace

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'
);
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The .gitignore was also updated to avoid committing legal source imports:

# ...
+.legal-sources/
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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",
      },
    }),
  ]);
}
Enter fullscreen mode Exit fullscreen mode

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)