Build the Pre-Registration Sign-Up System
Build a multi-file upload sign-up flow with dashboard and email alerts.
Introduction
30 Second Summary
Every matchmaking agency needs a way for new clients to sign up with their full profile, documents, and payment before the first meeting. Without a digital pre-registration system, staff have to collect everything by hand and hope nothing gets lost.
In this project, you will build a complete pre-registration sign-up flow where visitors submit their personal details, photos, and CV through a public form. Staff get notified instantly and can manage all submissions from the dashboard.
What You'll Build
A visitor lands on your sign-up page, fills in their profile, uploads three documents, picks a payment option, and hits submit. Seconds later, the registration appears in your dashboard and staff receive an email notification with a direct link to review it.
By the end of this project, you'll have:
- A multi-section sign-up form where visitors fill in their profile, upload passport photo, body photo, and CV, then choose to pay now or postpone to their appointment.
- A dashboard Registrations panel where staff can view every submission with payment status badges and delete unwanted entries.
- An automatic email notification that pings staff the moment a new registration arrives, with the registrant's details and a link to the dashboard.
- A full Appointments tab with CRUD, automatic date suggestions based on registration time, and personalized appointment emails sent to each user via Nodemailer.
- A live deployment of all three apps to Vercel with MongoDB Atlas as the production database, giving your clients a real URL to visit.
- Secret Mission: Build a stats and analytics panel on the dashboard homepage showing registration counts, appointment trends, and upcoming visits.
Are there any prerequisites?
This project continues from "Build the Blog and Contact System." You need all three repos running locally (agence-amour-marketing on port 3000, agence-amour-dashboard on port 3001, agence-amour-api on port 4000) with JWT auth, MongoDB, Multer, and Nodemailer already configured.
Before We Start
Before we dive in, take a moment to lock in what you are building and why. You are adding a pre-registration sign-up flow with file uploads, a staff dashboard panel, email notifications, and social media auto-posting stubs to your agency website.
Create the Registration Model and API Routes
Your API currently has no concept of pre-registrations. Without a data model and routes, there is nowhere to store sign-up submissions or serve them to the dashboard.
In this step you will define a Mongoose model for registration data, create protected CRUD routes for staff, and add a public route that accepts file uploads from visitors.
In this step, get ready to:
- Configure environment variables across all three repos and update CORS.
- Create the Registration model with personal, affective, and file fields.
- Create protected and public API routes for registrations.
Configure environment variables and CORS
All three repos need to know the API URL so they can communicate. The API also needs SMTP and staff email placeholders for the notification feature you will wire up later.
- In VS Code, open the .env file in your agence-amour-marketing repo and add this line:
NEXT_PUBLIC_API_URL=http://localhost:4000
- Add the same line to the .env file in your agence-amour-dashboard repo.
- Now open the .env file in your agence-amour-api repo and add these lines:
FRONTEND_URL=http://localhost:3000
DASHBOARD_URL=http://localhost:3001
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
STAFF_EMAIL=admin@agence-amour.com
Why are SMTP fields empty?
You are leaving SMTP_HOST, SMTP_PORT, SMTP_USER, and SMTP_PASS blank for now. In development, the mailer will auto-generate Ethereal test credentials. In production, you swap in your real SMTP values without touching any code.
- Still in the API repo, open server.ts and find your existing cors() call. Replace its configuration so it reads origins from environment variables:
// Read allowed origins from environment variables
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3000',
process.env.DASHBOARD_URL || 'http://localhost:3001',
];
app.use(cors({ origin: allowedOrigins, credentials: true }));
What does this CORS change do?
The cors middleware now accepts requests only from your two frontend origins. Passing an array to the origin option tells Express to reflect the matching origin in the response header.
When you deploy to your VPS, you change the env vars to your real domain and CORS updates automatically.
- Save all three .env files and server.ts.
- Switch to the terminal running your API server. Press Ctrl+C to stop it, then restart by running:
npx ts-node server.ts
- Confirm the server starts without errors. You should see your existing startup log with the MongoDB connection message and the port 4000 listening message.
Server won't start?
Check that your .env values have no extra spaces around the = sign. Also confirm you did not accidentally delete the cors import at the top of server.ts.
Ask for help: My API server fails to start after adding CORS env vars. What should I check?
Create the Registration model
The model defines what data a pre-registration contains. It covers personal info, affective profile, uploaded file paths, payment status, and a review timestamp for staff.
- In the agence-amour-api repo, create a new file models/Registration.ts and add the interface definition:
import { Schema, model, Document } from 'mongoose';
export interface IRegistration extends Document {
name: string;
phone: string;
dob: string;
address: string;
email: string;
profession: string;
matrimonialSituation: string;
children: number;
height: number;
weight: number;
city: string;
religion: string;
personalSummary: string;
passportPhoto: string;
bodyPhoto: string;
cv: string;
paymentStatus: 'paid' | 'postponed';
reviewedAt: Date | null;
}
What does this interface do?
The IRegistration interface gives TypeScript the shape of a registration document. Every field is typed so your editor catches typos and mismatched data before runtime.
File paths (passportPhoto, bodyPhoto, cv) store the location on disk where Multer saves uploads. paymentStatus is a union type restricted to exactly two values.
- Below the interface in the same file, add the schema and model export:
const registrationSchema = new Schema<IRegistration>({
name: { type: String, required: true },
phone: { type: String, required: true },
dob: { type: String, required: true },
address: { type: String, required: true },
email: { type: String, required: true },
profession: { type: String, required: true },
matrimonialSituation: { type: String, required: true },
children: { type: Number, default: 0 },
height: { type: Number, required: true },
weight: { type: Number, required: true },
city: { type: String, required: true },
religion: { type: String, required: true },
personalSummary: { type: String, required: true },
passportPhoto: { type: String, required: true },
bodyPhoto: { type: String, required: true },
cv: { type: String, required: true },
paymentStatus: { type: String, enum: ['paid', 'postponed'], required: true },
reviewedAt: { type: Date, default: null },
}, { timestamps: true });
const Registration = model<IRegistration>('Registration', registrationSchema);
export default Registration;
What does the schema do?
The schema maps each field to a MongoDB type with validation rules. Fields marked required: true must be present when creating a document.
The { timestamps: true } option automatically adds createdAt and updatedAt fields so you can sort registrations by date.
- Save models/Registration.ts.
- Restart the API server to confirm TypeScript compiles without errors.
Seeing a TypeScript error?
Make sure the import at the top uses { Schema, model, Document } from 'mongoose'. A missing curly brace or typo in the interface will cause a compilation failure.
Ask for help: My Registration model has a TypeScript error. What should I check?
✔️ Awesome, I've got everything!
Great. Double check you have saved models/Registration.ts and the server restarts cleanly.
ⓧ I'd like to double check the full code
Your models/Registration.ts file has two sections you built above. Scroll up and confirm:
- The IRegistration interface with all 18 typed fields is at the top.
- The registrationSchema with matching fields, { timestamps: true }, and the export default Registration line is at the bottom.
Create the registration routes
You need two sets of routes. Protected routes let authenticated staff list, view, update, and delete registrations. A public route lets unauthenticated visitors submit their sign-up with file uploads.
- Create a new file routes/registrations.ts in the API repo and add the imports, router setup, and GET routes:
import express, { Response, Router } from 'express';
import Registration from '../models/Registration';
import { authenticateJWT, requireClearance, AuthenticatedRequest } from '../middleware/auth';
const registrationsRouter: Router = express.Router();
registrationsRouter.use(authenticateJWT);
registrationsRouter.use(requireClearance(2));
// List all registrations, newest first
registrationsRouter.get('/', async (_req: AuthenticatedRequest, res: Response): Promise<void> => {
const registrations = await Registration.find().sort({ createdAt: -1 });
res.json(registrations);
});
// Get a single registration by ID
registrationsRouter.get('/:id', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
const registration = await Registration.findById(req.params.id);
if (!registration) { res.status(404).json({ error: 'Registration not found.' }); return; }
res.json(registration);
});
What does this code do?
The router applies authenticateJWT and requireClearance(2) as middleware to every route in this file. Only staff with clearance level 2 or higher can access these endpoints.
The GET / route returns all registrations sorted newest first. GET /:id returns a single document or a 404 if not found.
- Below the GET routes in the same file, add the PUT, DELETE, and export:
// Update a registration (status, notes, reviewedAt)
registrationsRouter.put('/:id', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
const updated = await Registration.findByIdAndUpdate(req.params.id, req.body, { new: true });
if (!updated) { res.status(404).json({ error: 'Registration not found.' }); return; }
res.json(updated);
});
// Delete a registration
registrationsRouter.delete('/:id', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
const deleted = await Registration.findByIdAndDelete(req.params.id);
if (!deleted) { res.status(404).json({ error: 'Registration not found.' }); return; }
res.json({ message: 'Registration deleted.' });
});
export default registrationsRouter;
What does this code do?
PUT /:id updates any fields sent in the request body. You will use this later for the "Mark as Reviewed" action. The { new: true } option tells Mongoose to return the updated document rather than the old one.
DELETE /:id removes a registration permanently from MongoDB.
- Save routes/registrations.ts.
- Now mount this router in server.ts. Add this import near the top with your other route imports:
import registrationsRouter from './routes/registrations';
- Then add this line where your other routers are mounted (near app.use('/api/contacts', ...)):
app.use('/api/registrations', registrationsRouter);
- Save server.ts and restart the API server.
Before you test, what do you think will happen when you call GET /api/registrations with a valid staff token? Will it return data or an empty list?
- Test the protected route by running this command (replace the token with a valid JWT from your login):
curl -X GET http://localhost:4000/api/registrations -H "Authorization: Bearer your-jwt-token-here"
You should see an empty JSON array [] returned with status 200. No registrations exist yet, but the route works.
Getting a 401 or 403?
A 401 means your token is invalid or expired. Log in again via your auth endpoint to get a fresh token. A 403 means the logged-in user does not have clearance level 2 or higher.
Ask for help: My GET /api/registrations returns 401 or 403. How do I fix my auth token?
- Next, add the public registration route. Open routes/public.ts and add this import at the top alongside your existing imports:
import Registration from '../models/Registration';
- Below your existing public routes in the same file, add the POST route that handles file uploads:
// Public pre-registration with file uploads
publicRouter.post('/registrations', upload.fields([
{ name: 'passportPhoto', maxCount: 1 },
{ name: 'bodyPhoto', maxCount: 1 },
{ name: 'cv', maxCount: 1 },
]), async (req: express.Request, res: express.Response): Promise<void> => {
const files = req.files as { [fieldname: string]: Express.Multer.File[] };
// Validate all three files are present
if (!files.passportPhoto || !files.bodyPhoto || !files.cv) {
res.status(400).json({ error: 'All three documents are required.' });
return;
}
const registration = new Registration({
...req.body,
children: parseInt(req.body.children) || 0,
height: parseFloat(req.body.height),
weight: parseFloat(req.body.weight),
passportPhoto: '/uploads/' + files.passportPhoto[0].filename,
bodyPhoto: '/uploads/' + files.bodyPhoto[0].filename,
cv: '/uploads/' + files.cv[0].filename,
});
await registration.save();
res.status(201).json(registration);
});
What does this route do?
The upload.fields() middleware from Multer accepts three named file inputs in a single request. Each file is saved to disk and its path is stored on the registration document.
Numeric fields (children, height, weight) arrive as strings from multipart form data, so they are parsed into numbers before saving.
- Save routes/public.ts and restart the API server one more time.
- Confirm the server starts without errors. The public POST route will be fully tested in the next step when you build the sign-up form.
Compilation error in public.ts?
Ensure upload (your Multer instance) is already imported or defined in routes/public.ts from the previous project. Also check that the Registration import path is correct ('../models/Registration').
Ask for help: My public.ts file has a TypeScript error after adding the registration route. What should I check?
✔️ Awesome, I've got everything!
Great. Double check you have saved routes/registrations.ts and routes/public.ts, and the server restarts cleanly.
ⓧ I'd like to double check the full code
Your routes/registrations.ts file has two sections you built above. Scroll up and confirm:
- Imports, router setup, authenticateJWT, requireClearance(2), and both GET routes are at the top.
- PUT /:id, DELETE /:id, and export default registrationsRouter are at the bottom.
Your API now has a complete registration system. The model defines the shape, protected routes give staff full CRUD access, and the public route is ready to accept sign-ups. Next up, you will build the visitor-facing form that submits data to this endpoint.
Build the Sign-Up Page on the Marketing Site
Your API can now receive and store pre-registration data, but visitors have no way to actually submit their profile. Without a public-facing form, the registration route sits idle.
In this step, you will build the /signup page on the marketing site. It collects personal details, an affective profile, three required document uploads, and a payment choice, then POSTs everything to your API as multipart form data.
In this step, get ready to:
- Create a multi-section sign-up form with four logical areas.
- Wire the form to POST multipart/form-data to the API.
- Test the full flow from form submission to MongoDB.
Set up the sign-up page with form state
The form needs to track 14 text fields, 3 file inputs, and a submission status. You will use React useState hooks to manage all of this in a single Client Component.
- In the agence-amour-marketing repo, create the file app/signup/page.tsx.
- Paste the following code to set up the component skeleton with all state variables:
'use client';
import { useState } from 'react';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export default function SignupPage() {
// Track all text form fields in a single state object
const [form, setForm] = useState({
name: '', phone: '', dob: '', address: '', email: '', profession: '',
matrimonialSituation: '', children: '0', height: '', weight: '',
city: '', religion: '', personalSummary: '', paymentStatus: 'postponed',
});
// Track file inputs separately (File objects can't go in a plain object)
const [passportPhoto, setPassportPhoto] = useState<File | null>(null);
const [bodyPhoto, setBodyPhoto] = useState<File | null>(null);
const [cv, setCv] = useState<File | null>(null);
// Track submission status for UI feedback
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Helper to update any text field in the form state
const update = (field: string, value: string) => setForm({ ...form, [field]: value });
What does this code do?
The form state object holds all 14 text fields with sensible defaults. File uploads are stored in separate useState hooks because File objects are not serializable in a plain object.
The update helper lets you set any field by name, keeping the onChange handlers short throughout the JSX.
Add the form submission handler
When the visitor clicks Submit, the handler must validate that all three documents are attached, pack everything into a FormData object, and POST it to the API's public registration route.
- Below the update function, add the first part of the submission handler:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('submitting');
setErrorMsg('');
// Validate that all three documents are selected
if (!passportPhoto || !bodyPhoto || !cv) {
setErrorMsg('All three documents (passport photo, body photo, CV) are required.');
setStatus('error');
return;
}
// Pack all text fields and files into FormData for multipart upload
const formData = new FormData();
Object.entries(form).forEach(([key, value]) => formData.append(key, value));
formData.append('passportPhoto', passportPhoto);
formData.append('bodyPhoto', bodyPhoto);
formData.append('cv', cv);
What does this code do?
The handler first checks that all three File states are set. If any are missing, it shows an error and stops early. Then it iterates over every text field in the form state object and appends each to a FormData instance. The three files are appended separately with field names that match what Multer expects on the API side.
- Directly below the formData.append('cv', cv) line, add the fetch call that sends the data to the API:
// Send to the public registration endpoint
try {
const res = await fetch(`${API_URL}/api/public/registrations`, {
method: 'POST',
body: formData,
});
if (res.ok) {
setStatus('success');
setForm({ name: '', phone: '', dob: '', address: '', email: '', profession: '', matrimonialSituation: '', children: '0', height: '', weight: '', city: '', religion: '', personalSummary: '', paymentStatus: 'postponed' });
setPassportPhoto(null);
setBodyPhoto(null);
setCv(null);
} else {
const data = await res.json();
setErrorMsg(data.error || 'Something went wrong.');
setStatus('error');
}
} catch {
setErrorMsg('Network error. Please try again.');
setStatus('error');
}
};
What does this code do?
The fetch call POSTs the FormData to your API. No Content-Type header is needed because the browser sets it automatically to multipart/form-data with the correct boundary.
On success, the form resets to its default state. On failure, the error message from the API is displayed. A catch block handles network errors separately.
Build the form UI with all four sections
The return statement renders a multi-section form. Each section groups related fields so the page is easy to scan.
- Below the closing }; of handleSubmit, add the return statement with the page heading, status messages, and the Personal Information section:
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-2">Pre-Registration</h1>
<p className="text-gray-600 mb-6">Fill in your profile to begin the matchmaking process.</p>
{status === 'success' && <div className="bg-green-50 border border-green-200 text-green-800 p-4 rounded mb-6">Registration submitted successfully! We will contact you shortly.</div>}
{status === 'error' && errorMsg && <div className="bg-red-50 border border-red-200 text-red-800 p-4 rounded mb-6">{errorMsg}</div>}
<form onSubmit={handleSubmit} className="space-y-8">
<section>
<h2 className="text-xl font-semibold mb-4">Personal Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input type="text" placeholder="Full Name" value={form.name} onChange={(e) => update('name', e.target.value)} className="w-full border p-2 rounded" required />
<input type="tel" placeholder="Phone" value={form.phone} onChange={(e) => update('phone', e.target.value)} className="w-full border p-2 rounded" required />
<input type="date" placeholder="Date of Birth" value={form.dob} onChange={(e) => update('dob', e.target.value)} className="w-full border p-2 rounded" required />
<input type="text" placeholder="Address" value={form.address} onChange={(e) => update('address', e.target.value)} className="w-full border p-2 rounded" required />
<input type="email" placeholder="Email" value={form.email} onChange={(e) => update('email', e.target.value)} className="w-full border p-2 rounded" required />
<input type="text" placeholder="Profession" value={form.profession} onChange={(e) => update('profession', e.target.value)} className="w-full border p-2 rounded" required />
</div>
</section>
What does this code do?
The status messages render conditionally. A green banner appears on success. A red banner shows the error message on failure. The Personal Information section uses a responsive 2-column grid with six required text inputs.
- Directly below the closing </section> of Personal Information, add the Affective Profile section:
<section>
<h2 className="text-xl font-semibold mb-4">Affective Profile</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<select value={form.matrimonialSituation} onChange={(e) => update('matrimonialSituation', e.target.value)} className="w-full border p-2 rounded" required>
<option value="">Matrimonial Situation</option>
<option value="single">Single</option>
<option value="divorced">Divorced</option>
<option value="widowed">Widowed</option>
</select>
<input type="number" placeholder="Number of Children" value={form.children} onChange={(e) => update('children', e.target.value)} className="w-full border p-2 rounded" min="0" />
<input type="number" placeholder="Height (cm)" value={form.height} onChange={(e) => update('height', e.target.value)} className="w-full border p-2 rounded" required />
<input type="number" placeholder="Weight (kg)" value={form.weight} onChange={(e) => update('weight', e.target.value)} className="w-full border p-2 rounded" required />
<input type="text" placeholder="City" value={form.city} onChange={(e) => update('city', e.target.value)} className="w-full border p-2 rounded" required />
<select value={form.religion} onChange={(e) => update('religion', e.target.value)} className="w-full border p-2 rounded" required>
<option value="">Religion</option>
<option value="christian">Christian</option>
<option value="muslim">Muslim</option>
<option value="other">Other</option>
<option value="none">None</option>
</select>
</div>
<textarea placeholder="Personal Summary" value={form.personalSummary} onChange={(e) => update('personalSummary', e.target.value)} className="w-full border p-2 rounded mt-4 h-32" required />
</section>
What does this code do?
The Affective Profile section collects relationship and physical details. The matrimonial situation and religion fields use dropdowns with predefined options. The personal summary textarea gives visitors space to describe themselves and what they are looking for.
- Below the Affective Profile </section>, add the Required Documents and Payment sections, along with the submit button and closing tags:
<section>
<h2 className="text-xl font-semibold mb-4">Required Documents</h2>
<div className="space-y-4">
<div><label className="block text-sm font-medium mb-1">Passport Photo (required)</label><input type="file" accept="image/*" onChange={(e) => setPassportPhoto(e.target.files?.[0] || null)} className="w-full" required /></div>
<div><label className="block text-sm font-medium mb-1">Full Body Photo (required)</label><input type="file" accept="image/*" onChange={(e) => setBodyPhoto(e.target.files?.[0] || null)} className="w-full" required /></div>
<div><label className="block text-sm font-medium mb-1">CV / Resume (required)</label><input type="file" accept=".pdf,.doc,.docx" onChange={(e) => setCv(e.target.files?.[0] || null)} className="w-full" required /></div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Payment</h2>
<div className="border rounded p-4 mb-4 text-center">
<p className="text-sm text-gray-500 mb-2">Scan to pay via Orange Money</p>
<div className="w-48 h-48 mx-auto bg-gray-200 border-2 border-dashed border-gray-400 flex items-center justify-center"><span className="text-gray-400 text-sm">QR Code Placeholder</span></div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2"><input type="radio" name="paymentStatus" value="paid" checked={form.paymentStatus === 'paid'} onChange={(e) => update('paymentStatus', e.target.value)} /><span>I have paid via QR code</span></label>
<label className="flex items-center gap-2"><input type="radio" name="paymentStatus" value="postponed" checked={form.paymentStatus === 'postponed'} onChange={(e) => update('paymentStatus', e.target.value)} /><span>Postpone payment to RDV date</span></label>
</div>
</section>
<button type="submit" disabled={status === 'submitting'} className="w-full bg-blue-600 text-white py-3 rounded font-semibold hover:bg-blue-700 disabled:opacity-50">{status === 'submitting' ? 'Submitting...' : 'Submit Pre-Registration'}</button>
</form>
</div>
);
}
What does this code do?
The Documents section has three file inputs. Passport and body photos accept any image format. The CV accepts PDF and Word documents. Each input updates its corresponding File state directly.
The Payment section displays a placeholder QR code area (you will replace this with your real Orange Money QR image when deploying). Two radio buttons let the visitor declare they paid or postpone to their appointment date.
- Save app/signup/page.tsx.
- Open http://localhost:3000/signup in your browser.
You should see a multi-section form with Personal Information fields at the top, followed by Affective Profile dropdowns and inputs, three file upload fields, a QR code placeholder box, payment radio buttons, and a blue Submit button at the bottom.
Page not rendering?
Check that you have the 'use client' directive on the very first line of the file. Without it, Next.js treats the component as a Server Component and useState will fail.
Make sure the closing braces and parentheses match. The file should end with ); (closing the return) followed by } (closing the function).
help me debug my signup page not rendering
Before you submit, what do you think will happen when you fill in the form and click the button? Will the data reach MongoDB?
- Open an incognito window and navigate to http://localhost:3000/signup.
- Fill in all Personal Information fields with test data (any values are fine).
- Select a Matrimonial Situation, fill in height, weight, city, religion, and write a short personal summary.
- Attach any image file for the passport photo and body photo fields.
- Attach any PDF file for the CV field.
- Select Postpone payment to RDV date.
- Click Submit Pre-Registration.
You should see a green success banner reading "Registration submitted successfully! We will contact you shortly." Check the API terminal and confirm a successful POST logged with status 200.
Seeing a network error or 500?
Confirm your .env.local file in agence-amour-marketing contains NEXT_PUBLIC_API_URL=http://localhost:4000. Restart the dev server after adding environment variables.
Make sure the API server is still running on port 4000 and that your public route at /api/public/registrations is mounted correctly in server.ts.
help me debug my form submission failing with a network or 500 error
✔️ Awesome, I've got everything!
Great. Double check you have saved app/signup/page.tsx in the agence-amour-marketing repo.
ⓧ I'd like to double check the full code
Here is the complete app/signup/page.tsx file. Scroll through and confirm each section is present in order: state declarations, handleSubmit, then the return with Personal Information, Affective Profile, Required Documents, and Payment.
- Lines 1-22: 'use client' directive, import, API_URL constant, component function, all useState hooks, and the update helper.
- Lines 24-50: The handleSubmit function with file validation, FormData construction, fetch call, success reset, and error handling.
- Lines 52-68: Return opening, page heading, conditional status banners, form opening, and the Personal Information section with 6 inputs.
- Lines 69-92: Affective Profile section with matrimonial situation dropdown, number inputs, city, religion dropdown, and personal summary textarea.
- Lines 93-114: Required Documents section (3 file inputs), Payment section (QR placeholder + radio buttons), submit button, and closing tags.
Your sign-up page is live and working. Visitors can fill out their full profile, upload their documents, and submit. Next up, you will give staff the ability to see these submissions from the dashboard.
Build the Dashboard Registrations Panel
Your sign-up form is saving registrations to MongoDB, but staff have no way to see them. Submissions just sit in the database, invisible and unreviewed.
In this step, you will build a dashboard panel in the agence-amour-dashboard repo that fetches all pre-registrations from the API and displays them in a list with payment badges, view links, and a delete action.
In this step, get ready to:
- Create a registrations page in the dashboard that fetches data from the protected API route.
- Display each registration with status badges and a delete action.
- Verify your test submission from Step 2 appears in the dashboard list.
Create the registrations page with data fetching
The dashboard needs to call the protected GET /api/registrations endpoint you built in Step 1. That route requires a JWT token with clearance level 2 or higher.
- In VS Code, switch to the agence-amour-dashboard repo.
- Create a new folder app/dashboard/registrations/ and inside it create a file called page.tsx.
- Add the following code to set up the component with its type definition and data fetching:
'use client';
import { useEffect, useState } from 'react';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// TypeScript interface matching the Registration model fields we display
interface Registration {
_id: string;
name: string;
email: string;
phone: string;
paymentStatus: string;
reviewedAt: string | null;
createdAt: string;
}
export default function RegistrationsPage() {
const [registrations, setRegistrations] = useState<Registration[]>([]);
// Fetch all registrations from the protected API route
async function fetchRegistrations() {
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}/api/registrations`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) setRegistrations(await res.json());
}
useEffect(() => { fetchRegistrations(); }, []);
What does this code do?
- The Registration interface defines only the fields the list view needs to display (not the full model).
- fetchRegistrations() reads the JWT token from localStorage and passes it in the Authorization header so the protected route allows access.
- The useEffect calls the fetch function once on mount, loading registrations as soon as the page renders.
Add the delete handler and list display
Now you need the delete action and the JSX that renders each registration as a row with payment status badges and action buttons.
- Still in page.tsx, add the following code directly below the useEffect line:
// Delete a registration and refresh the list
async function handleDelete(id: string) {
const token = localStorage.getItem('token');
await fetch(`${API_URL}/api/registrations/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
fetchRegistrations();
}
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-6">Pre-Registrations</h1>
{registrations.length === 0 && <p>No registrations yet.</p>}
<div className="space-y-3">
{registrations.map((r) => (
<div key={r._id} className="border p-4 rounded flex justify-between items-center hover:bg-gray-50">
<div>
<p className="font-semibold">{r.name}</p>
<p className="text-sm text-gray-500">{r.email} | {r.phone}</p>
<p className="text-xs text-gray-400">{new Date(r.createdAt).toLocaleDateString()}</p>
</div>
<div className="flex items-center gap-3">
<span className={r.paymentStatus === 'paid' ? 'text-green-600 font-bold text-sm' : 'text-orange-600 font-bold text-sm'}>{r.paymentStatus}</span>
{r.reviewedAt && <span className="text-green-600 text-xs">Reviewed</span>}
<a href={`/dashboard/registrations/${r._id}`} className="text-blue-600 text-sm underline">View</a>
<button onClick={() => handleDelete(r._id)} className="text-red-600 text-sm">Delete</button>
</div>
</div>
))}
</div>
</div>
);
}
What does this code do?
- handleDelete() calls the DELETE endpoint with the registration's ID, then re-fetches the full list so the UI stays in sync.
- The empty state (registrations.length === 0) shows a friendly message when no submissions exist yet.
- Payment status uses a conditional class: green text for paid, orange text for postponed. This gives staff an instant visual signal.
- The View link points to /dashboard/registrations/[id] which you will build in the Secret Mission.
- Save app/dashboard/registrations/page.tsx.
Seeing a compile error?
- Make sure you have the closing curly brace for the component function at the very end of the file.
- Check that the folder path is exactly app/dashboard/registrations/page.tsx inside the agence-amour-dashboard repo, not the marketing repo.
- Confirm the NEXT_PUBLIC_API_URL variable exists in the dashboard's .env file (set to http://localhost:4000).
Why is my registrations page not compiling?
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your file contains both chunks in order:
- Chunk 1: The 'use client' directive, imports, API_URL constant, Registration interface, component declaration, fetchRegistrations function, and useEffect.
- Chunk 2: The handleDelete function and the full return JSX with the list, badges, View link, Delete button, and empty state.
Verify the registrations panel in the browser
Before you check the browser, think about what you expect to see. You submitted a test registration in Step 2. Do you think it will appear in the dashboard list right away?
- Open http://localhost:3001 in your browser.
- Log in with a staff account that has clearance level 2 or higher.
- Navigate to /dashboard/registrations in the URL bar.
You should see the test registration you submitted in Step 2 listed with the visitor's name, email, date, and a payment status badge (orange for postponed or green for paid, depending on what you chose).
Page loads but shows no registrations?
- Open your browser's DevTools (**F12**) and check the **Network** tab. Look for the request to /api/registrations. If it returns 401, your token may have expired. Log out and log back in.
- If the request returns 403, your staff account clearance level is below 2. Use an account with clearance 2 or higher.
- Make sure the API server is still running on port 4000. Check the terminal where you started it.
My registrations page loads but I cannot see any data.
Staff now have full visibility into pre-registration submissions straight from the dashboard. Next up, you will wire up automatic email notifications so staff get pinged the moment a new registration arrives.
Add Email Notification on New Registration
Your dashboard now lists every pre-registration, but staff have to manually open the page to discover new sign-ups. A submission could sit unnoticed for hours.
An automatic email notification solves this. The moment a visitor submits their registration, staff receive an email with the registrant's details and a direct link to the dashboard. Your mailer already supports production SMTP and Ethereal fallback for development, so you just need to add the notification function and wire it into the route.
In this step, get ready to:
- Add a sendRegistrationNotification function to the existing mailer service.
- Call the notification from the public registration route with error-safe wrapping.
- Test the full flow and confirm the Ethereal preview email arrives.
Add the sendRegistrationNotification function
Your services/mailer.ts already has a getTransporter() function that reads SMTP credentials from environment variables and falls back to Ethereal when they are empty. You will add a new function that uses this same transporter to notify staff.
- In the agence-amour-api repo, open services/mailer.ts.
- Find the closing brace of the sendConfirmationEmail function at the bottom of the file.
- Add the following function below it:
export async function sendRegistrationNotification(name: string, email: string, phone: string): Promise<void> {
const transport = await getTransporter();
const staffEmail = process.env.STAFF_EMAIL || 'admin@agence-amour.com';
const dashboardUrl = process.env.DASHBOARD_URL || 'http://localhost:3001';
const info = await transport.sendMail({
from: '"Agence Amour System" <system@agence-amour.com>',
to: staffEmail,
subject: `New Pre-Registration: ${name}`,
text: `A new pre-registration has been submitted.\n\nName: ${name}\nEmail: ${email}\nPhone: ${phone}\n\nView all registrations: ${dashboardUrl}/dashboard/registrations`,
html: `<h2>New Pre-Registration</h2><p>A new pre-registration has been submitted.</p><ul><li><strong>Name:</strong> ${name}</li><li><strong>Email:</strong> ${email}</li><li><strong>Phone:</strong> ${phone}</li></ul><p><a href="${dashboardUrl}/dashboard/registrations">View in Dashboard</a></p>`,
});
console.log('Registration notification sent:', info.messageId);
const previewUrl = nodemailer.getTestMessageUrl(info);
if (previewUrl) console.log('Preview URL:', previewUrl);
}
What does this function do?
It sends an email to the address in your STAFF_EMAIL environment variable (defaulting to admin@agence-amour.com). The email body includes the registrant's name, email, and phone, plus a clickable link to the dashboard registrations page.
Since your SMTP env vars are empty in development, getTransporter() auto-creates an Ethereal test account. The email lands in Ethereal's web inbox, and the preview URL prints to your terminal so you can view it.
- Save services/mailer.ts.
- Check your API terminal. The server should recompile without errors.
Seeing a compilation error?
- Make sure the new function is placed AFTER the closing brace of sendConfirmationEmail, not inside it.
- Check that you have the export keyword at the start of the function declaration.
- Verify the template literals use backticks, not single quotes.
Help me fix my mailer.ts compilation error.
Wire the notification into the registration route
The notification function exists, but nothing calls it yet. You need to trigger it from the public POST route after a registration is saved.
- Open routes/public.ts in the agence-amour-api repo.
- At the top of the file, add the import for the new function:
import { sendRegistrationNotification } from '../services/mailer';
- Scroll down to the POST /registrations handler. Find the line where the registration is saved to MongoDB (the await registration.save() or similar call).
- Add this try/catch block immediately AFTER the save, before the success response:
// Notify staff about new registration (non-blocking)
try {
await sendRegistrationNotification(registration.name, registration.email, registration.phone);
} catch (err) {
console.error('Failed to send registration notification:', err);
}
Why wrap in try/catch?
Email delivery can fail for many reasons: network timeouts, invalid SMTP credentials, rate limits. Wrapping the call in try/catch ensures that email failure does not block the registration response. The visitor still gets their success message, and the error is logged for debugging.
- Save routes/public.ts.
- Confirm the API terminal recompiles without errors.
Import not resolving?
- Verify the import path uses ../services/mailer (with the correct relative path from your routes folder).
- Confirm the function name matches exactly: sendRegistrationNotification.
Help me fix my import for sendRegistrationNotification.
Test the full email notification flow
Before you submit a test registration, think about what you expect to happen. The SMTP env vars are empty, so the mailer should fall back to Ethereal and print a preview URL to the API terminal.
- Open an incognito browser window and navigate to http://localhost:3000/signup.
- Fill in all fields with test data (any values are fine).
- Attach any image file for the passport photo and body photo, and any PDF for the CV.
- Select Postpone payment to RDV date.
- Click Submit Pre-Registration.
What do you expect to see in the API terminal? Will the notification succeed or fail?
- Switch to the API terminal and look for the output.
You should see lines like:
[Mailer] Using Ethereal test account: some-auto-generated@ethereal.email
Registration notification sent: <message-id>
Preview URL: https://ethereal.email/message/...
- Copy the Preview URL from the terminal and open it in your browser.
- Verify the email shows the subject line with the registrant's name, and the body contains their name, email, phone, and a link to the dashboard registrations page.
Why Ethereal instead of real email?
Ethereal is a fake SMTP service from Nodemailer that captures outgoing emails without delivering them. It lets you test email formatting and delivery logic without configuring real credentials. When you deploy to your VPS, you swap in real SMTP values and the same code sends actual emails.
🙋♀️ No Preview URL in the terminal?
- Make sure the form submission succeeded (you see a green success message on the /signup page). If the form shows an error, the route did not reach the notification call.
- Look for an error message like Failed to send registration notification in the terminal. This means the try/catch fired. Check that your sendRegistrationNotification is exported correctly from services/mailer.ts.
- Confirm your SMTP_HOST, SMTP_USER, and SMTP_PASS are empty (not removed) in your .env file so the Ethereal fallback activates.
Help me debug my email notification not showing a Preview URL.
✔️ Awesome, I've got everything!
Great. Double check you have saved both services/mailer.ts and routes/public.ts.
ⓧ I'd like to double check the full code
Here is the full services/mailer.ts with both the existing sendConfirmationEmail and your new sendRegistrationNotification. Scroll up and confirm each section is present in order:
- The import nodemailer and transporter variable at the top.
- The getTransporter() function with env-var reading and Ethereal fallback.
- The sendConfirmationEmail function (from the previous project).
- The new sendRegistrationNotification function at the bottom.
Staff now get instant alerts for every new sign-up. Next, you will build the Appointments tab so the system automatically suggests visit dates and emails users with their appointment details.
Build the Appointment Tab on the Dashboard
Staff now get notified the moment someone registers. But the next question is immediate: when should this person come in? Right now, staff have to manually figure out appointment dates and send emails by hand.
In this step, you will build a full Appointments system. The API analyzes the registration date to suggest a visit window, sends an automated email to the user with their appointment details via Nodemailer, and exposes CRUD routes restricted to staff with clearance level 1 or 2. The dashboard gets a new Appointments tab where staff can view, create, edit, and delete appointments.
In this step, get ready to:
- Create an Appointment model and CRUD routes protected by clearance level 1-2.
- Build date-analysis logic that suggests a visit date based on registration time.
- Send automated appointment emails and reminders using the existing mailer service.
Create the Appointment model
Each appointment links a registration to a specific date and time. The model also tracks the status (scheduled, completed, cancelled) and stores a reminder flag so the system knows whether the reminder email has already been sent.
- In the agence-amour-api repo, create a new file models/Appointment.ts.
- Add the schema and model by pasting this code:
import mongoose from 'mongoose';
// Schema for agency visit appointments
const appointmentSchema = new mongoose.Schema({
registration: { type: mongoose.Schema.Types.ObjectId, ref: 'Registration', required: true },
name: { type: String, required: true },
email: { type: String, required: true },
phone: { type: String, required: true },
date: { type: Date, required: true },
time: { type: String, required: true },
status: { type: String, enum: ['scheduled', 'completed', 'cancelled'], default: 'scheduled' },
reminderSent: { type: Boolean, default: false },
notes: { type: String, default: '' },
createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
}, { timestamps: true });
export default mongoose.model('Appointment', appointmentSchema);
What does this schema do?
The registration field links back to the original sign-up so staff can pull up the full profile. The status enum lets staff track whether the appointment happened, and reminderSent prevents duplicate reminder emails.
The createdBy field records which staff member created the appointment, useful for audit trails. The timestamps: true option auto-adds createdAt and updatedAt fields.
- Save models/Appointment.ts.
- Check your API terminal. The server should recompile without errors.
Seeing a compilation error?
Make sure mongoose is imported at the top of the file. Also verify the ref: 'Registration' string matches your Registration model name exactly (capital R).
Help me fix my Appointment model compilation error.
Build the appointments CRUD routes
The appointments routes need full CRUD (list, create, update, delete) and must be restricted to staff with clearance level 1 or 2. The route also includes date-suggestion logic that analyzes when the registration was submitted and proposes the next available weekday.
- Create a new file routes/appointments.ts in the agence-amour-api repo.
- Add the imports, router setup, and the date-suggestion helper by pasting this code:
import { Router, Request, Response } from 'express';
import Appointment from '../models/Appointment';
import Registration from '../models/Registration';
import { sendAppointmentEmail } from '../services/mailer';
const router = Router();
// Suggest next available weekday, 3 days after registration
function suggestAppointmentDate(registrationDate: Date): Date {
const suggested = new Date(registrationDate);
suggested.setDate(suggested.getDate() + 3);
// Skip weekends
while (suggested.getDay() === 0 || suggested.getDay() === 6) {
suggested.setDate(suggested.getDate() + 1);
}
return suggested;
}
What does suggestAppointmentDate do?
It takes the registration creation date, adds 3 days (giving staff time to review the profile), then skips forward past any weekend days. The result is the next available weekday for the visit.
- Add the GET (list all) and POST (create) routes below the helper function:
// GET all appointments
router.get('/', async (req: Request, res: Response) => {
try {
const appointments = await Appointment.find()
.sort({ date: 1 })
.populate('registration', 'name email phone');
res.json(appointments);
} catch (err) {
res.status(500).json({ error: 'Failed to fetch appointments' });
}
});
// POST create appointment (manual or auto-suggested)
router.post('/', async (req: Request, res: Response) => {
try {
const { registrationId, date, time, notes } = req.body;
const registration = await Registration.findById(registrationId);
if (!registration) return res.status(404).json({ error: 'Registration not found' });
// Use provided date or suggest one
const appointmentDate = date ? new Date(date) : suggestAppointmentDate(registration.createdAt);
const appointmentTime = time || '10:00';
const appointment = new Appointment({
registration: registrationId,
name: registration.name,
email: registration.email,
phone: registration.phone,
date: appointmentDate,
time: appointmentTime,
notes: notes || '',
createdBy: (req as any).user?.id,
});
await appointment.save();
// Send appointment email to the user
await sendAppointmentEmail(registration.email, registration.name, appointmentDate, appointmentTime);
res.status(201).json(appointment);
} catch (err) {
res.status(500).json({ error: 'Failed to create appointment' });
}
});
Why does POST accept an optional date?
When staff create an appointment from the dashboard without specifying a date, the system auto-suggests one using suggestAppointmentDate(). If a user requests a specific date (via the Contact page), staff can pass it explicitly. Both paths land in the same route.
- Add the PUT (update) and DELETE routes, then export the router:
// PUT update appointment
router.put('/:id', async (req: Request, res: Response) => {
try {
const updated = await Appointment.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true }
);
if (!updated) return res.status(404).json({ error: 'Appointment not found' });
res.json(updated);
} catch (err) {
res.status(500).json({ error: 'Failed to update appointment' });
}
});
// DELETE appointment
router.delete('/:id', async (req: Request, res: Response) => {
try {
const deleted = await Appointment.findByIdAndDelete(req.params.id);
if (!deleted) return res.status(404).json({ error: 'Appointment not found' });
res.json({ message: 'Appointment deleted' });
} catch (err) {
res.status(500).json({ error: 'Failed to delete appointment' });
}
});
export default router;
- Save routes/appointments.ts.
TypeScript error on req.body or req.params?
Make sure you import Request and Response from express at the top. If you see a type error on (req as any).user, that is expected since the auth middleware attaches user dynamically.
Help me fix my appointments route TypeScript errors.
Mount the route and add the appointment email function
You need to register the new route in your server and add the email function that notifies users of their appointment date.
- Open server.ts (or index.ts, whichever is your main entry file) in agence-amour-api.
- Add the import alongside your existing route imports:
import appointmentsRouter from './routes/appointments';
- Mount it with clearance level 2 protection, below your other protected routes:
// Appointments - clearance level 1 or 2 only
app.use('/api/appointments', authMiddleware, requireClearance(2), appointmentsRouter);
Why requireClearance(2)?
Your clearance system grants access to the specified level and above. Level 1 is full access, level 2 is most pages. By passing 2 to requireClearance(), both level 1 and level 2 staff can manage appointments, but level 3 (homepage-only) users cannot.
- Now open services/mailer.ts and add the sendAppointmentEmail function below your existing functions:
export async function sendAppointmentEmail(
email: string, name: string, date: Date, time: string
): Promise<void> {
const transport = await getTransporter();
const formattedDate = date.toLocaleDateString('fr-FR', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
});
const info = await transport.sendMail({
from: '"Agence Amour" <rdv@agence-amour.com>',
to: email,
subject: `Your appointment is confirmed - ${formattedDate}`,
text: `Hello ${name},\n\nYour visit to Agence Amour is scheduled for:\nDate: ${formattedDate}\nTime: ${time}\n\nPlease bring a valid ID and arrive 10 minutes early.\n\nSee you soon!\nAgence Amour`,
html: `<h2>Appointment Confirmed</h2><p>Hello ${name},</p><p>Your visit to Agence Amour is scheduled for:</p><ul><li><strong>Date:</strong> ${formattedDate}</li><li><strong>Time:</strong> ${time}</li></ul><p>Please bring a valid ID and arrive 10 minutes early.</p><p>See you soon!<br/>Agence Amour</p>`,
});
console.log('Appointment email sent:', info.messageId);
const previewUrl = nodemailer.getTestMessageUrl(info);
if (previewUrl) console.log('Preview URL:', previewUrl);
}
- Save both server.ts and services/mailer.ts.
- Check your API terminal. The server should recompile without errors.
Import not resolving for sendAppointmentEmail?
Make sure you exported the function with the export keyword. Also check that the import in routes/appointments.ts uses the path '../services/mailer' (two dots, since routes and services are sibling folders).
Help me fix my sendAppointmentEmail import.
Build the Appointments tab on the dashboard
The backend is ready. Now you need the dashboard page where staff can see all appointments, create new ones (manually or auto-suggested), edit dates, and cancel.
- In the agence-amour-dashboard repo, create a new folder app/dashboard/appointments.
- Inside that folder, create page.tsx and add the imports, state, and fetch logic:
'use client';
import { useEffect, useState } from 'react';
const API_URL = process.env.NEXT_PUBLIC_API_URL;
// TypeScript interface for appointment objects
interface Appointment {
_id: string;
name: string;
email: string;
phone: string;
date: string;
time: string;
status: 'scheduled' | 'completed' | 'cancelled';
notes: string;
registration: { _id: string; name: string };
}
export default function AppointmentsPage() {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [showForm, setShowForm] = useState(false);
const [registrationId, setRegistrationId] = useState('');
const [date, setDate] = useState('');
const [time, setTime] = useState('10:00');
const [notes, setNotes] = useState('');
What does this set up?
The 'use client' directive makes this a Client Component so you can use React hooks. The state variables track the appointments list, form visibility, and form field values for creating new appointments.
- Add the data fetching, create, and delete handler functions below the state declarations:
// Fetch all appointments on mount
useEffect(() => {
fetchAppointments();
}, []);
async function fetchAppointments() {
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}/api/appointments`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) setAppointments(await res.json());
}
// Create a new appointment
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
const token = localStorage.getItem('token');
const res = await fetch(`${API_URL}/api/appointments`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ registrationId, date: date || undefined, time, notes }),
});
if (res.ok) {
setShowForm(false);
setRegistrationId('');
setDate('');
setTime('10:00');
setNotes('');
fetchAppointments();
}
}
// Delete an appointment
async function handleDelete(id: string) {
const token = localStorage.getItem('token');
await fetch(`${API_URL}/api/appointments/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
fetchAppointments();
}
- Add the return JSX with the appointments table and creation form:
return (
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Appointments</h1>
<button onClick={() => setShowForm(!showForm)} className="bg-blue-600 text-white px-4 py-2 rounded text-sm">
{showForm ? 'Cancel' : 'New Appointment'}
</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-gray-50 p-4 rounded mb-6 grid grid-cols-2 gap-3">
<input value={registrationId} onChange={e => setRegistrationId(e.target.value)} placeholder="Registration ID" className="border p-2 rounded" required />
<input type="date" value={date} onChange={e => setDate(e.target.value)} className="border p-2 rounded" />
<input type="time" value={time} onChange={e => setTime(e.target.value)} className="border p-2 rounded" />
<input value={notes} onChange={e => setNotes(e.target.value)} placeholder="Notes (optional)" className="border p-2 rounded" />
<button type="submit" className="bg-green-600 text-white px-4 py-2 rounded col-span-2">Create Appointment</button>
</form>
)}
<table className="w-full text-sm">
<thead className="bg-gray-100">
<tr>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Date</th>
<th className="p-2 text-left">Time</th>
<th className="p-2 text-left">Status</th>
<th className="p-2 text-left">Actions</th>
</tr>
</thead>
<tbody>
{appointments.map(apt => (
<tr key={apt._id} className="border-b">
<td className="p-2">{apt.name}</td>
<td className="p-2">{new Date(apt.date).toLocaleDateString('fr-FR')}</td>
<td className="p-2">{apt.time}</td>
<td className="p-2"><span className={apt.status === 'scheduled' ? 'text-blue-600' : apt.status === 'completed' ? 'text-green-600' : 'text-red-600'}>{apt.status}</span></td>
<td className="p-2"><button onClick={() => handleDelete(apt._id)} className="text-red-600 text-xs">Delete</button></td>
</tr>
))}
</tbody>
</table>
</div>
);
}
- Save app/dashboard/appointments/page.tsx.
- Open your dashboard at http://localhost:3001 and navigate to /dashboard/appointments.
- You should see the Appointments page with an empty table and a New Appointment button.
Page showing a 404?
Make sure the file is located at app/dashboard/appointments/page.tsx (not pages/). Next.js App Router uses the app/ directory for routing. Restart your dev server if the hot-reload did not pick up the new folder.
Help me debug why my /dashboard/appointments page is showing 404.
Now test the full flow by creating an appointment.
- Click New Appointment.
- Paste a registration ID from your Registrations page (copy it from the URL or network tab).
- Leave the date field empty to test the auto-suggestion logic.
- Click Create Appointment.
Before you check the API terminal, what do you expect to see? The appointment should be created with a date 3+ days from the registration date (skipping weekends), and the mailer should log an Ethereal preview URL.
- Switch to your API terminal and look for the Ethereal preview URL.
- Open the preview URL in your browser and verify the email shows the correct name, date, and time.
- Back on the dashboard, confirm the new appointment appears in the table with status scheduled.
No appointment appearing in the table?
Open your browser DevTools and check the Network tab. Look for the POST request to /api/appointments. If it returns 403, your logged-in user may have clearance level 3. Log in with a level 1 or 2 account.
If it returns 404 on Registration not found, double-check the registration ID you pasted. It should be a valid MongoDB ObjectId from your registrations collection.
Help me debug why my appointment creation is failing.
Your appointment system is live. Staff can schedule visits, users get automatic emails with their date and time, and the whole thing is locked behind clearance level 1-2. Next up, you will deploy all three repos to production so your clients can see the live result.
Deploy to Vercel and MongoDB Atlas
Your appointment system is working locally. Staff can schedule visits, users get emails, and the dashboard shows everything. But your clients cannot see any of it because everything runs on localhost.
In this step, you will deploy all three repositories to live URLs. The two Next.js apps (agence-amour-marketing and agence-amour-dashboard) and the Express API (agence-amour-api) each get their own Vercel project. Your local MongoDB is replaced by a free MongoDB Atlas cluster in the cloud, and file uploads move to Vercel Blob storage.
In this step, get ready to:
- Create a free MongoDB Atlas cluster and get a production connection string.
- Deploy all three repos to Vercel with the correct environment variables.
- Replace local file uploads with Vercel Blob storage for production.
Create a free MongoDB Atlas cluster
MongoDB Atlas gives you a free 512MB cloud database. You keep all your Mongoose models and queries unchanged. The only difference is the connection string in your .env file.
- Open https://www.mongodb.com/cloud/atlas/register and create a free account (or log in if you already have one).
- Click Build a Database.
- Select the M0 Free tier.
- Choose a cloud provider and region close to your users (any works for testing).
- Name your cluster agence-amour and click Create Deployment.
What is MongoDB Atlas?
Atlas is MongoDB's cloud-hosted database service. The free M0 tier gives you 512MB of storage, shared RAM, and automatic backups. Your Mongoose code connects to it exactly the same way as local MongoDB. The only thing that changes is the connection string.
- Once the cluster is created, click Connect.
- Choose Drivers as your connection method.
- Copy the connection string. It looks like:
mongodb+srv://<username>:<password>@agence-amour.xxxxx.mongodb.net/agence-amour?retryWrites=true&w=majority
- Under Security > Database Access, create a database user with a username and password.
- Under Security > Network Access, click Add IP Address and select Allow Access from Anywhere (required for Vercel's dynamic IPs).
Why allow access from anywhere?
Vercel's serverless functions run from rotating IP addresses. You cannot whitelist a fixed IP. For a production app with sensitive data, you would use Vercel's Secure Compute feature, but for this project the open whitelist is fine.
- Replace <username> and <password> in the connection string with your database user's credentials.
- Test the connection locally by updating MONGODB_URI in your agence-amour-api/.env file with the Atlas string and restarting your API.
- Confirm your API connects successfully (you should see your connection log in the terminal).
Connection timing out?
Check that your IP is whitelisted in Atlas under Network Access. If you selected Allow Access from Anywhere, it should show 0.0.0.0/0 in the list. Also verify your username and password have no special characters that need URL encoding.
Help me debug my MongoDB Atlas connection timeout.
Deploy the API to Vercel
Vercel detects Express apps automatically. Your API uses app.listen(), which is one of the entry patterns Vercel looks for. The key change is that express.static() is ignored on Vercel, so uploaded files need a different solution (you will add Vercel Blob in the next substep).
- Make sure your agence-amour-api repo is pushed to GitHub.
- Open https://vercel.com and sign in with your GitHub account.
- Click Add New... > Project.
- Import the agence-amour-api repository.
- In the Environment Variables section, add these variables:
MONGODB_URI=mongodb+srv://<your-atlas-connection-string>
JWT_SECRET=<your-jwt-secret>
STAFF_EMAIL=admin@agence-amour.com
DASHBOARD_URL=https://agence-amour-dashboard.vercel.app
FRONTEND_URL=https://agence-amour-marketing.vercel.app
You will update the URLs later
The dashboard and frontend URLs are placeholders. After you deploy those two projects, come back and update these values with the actual Vercel URLs.
- Click Deploy.
- Wait for the build to complete. Vercel should detect the Express app and deploy it as a single function.
- Once deployed, copy the live URL (e.g., agence-amour-api.vercel.app).
- Test it by navigating to https://agence-amour-api.vercel.app/api/articles in your browser. You should see a JSON response (empty array or your articles).
Build failing on Vercel?
Check the build logs. Common issues: missing typescript in dependencies (it might be in devDependencies only), or the entry file is not at a recognized path (server.ts, src/server.ts, index.ts, or app.ts).
If your entry file is compiled to dist/, make sure your package.json build script compiles TypeScript and the main field points to the output.
Help me fix my Vercel deployment build failure for the API.
Deploy the marketing site and dashboard
Both Next.js apps deploy to Vercel with zero configuration. Vercel auto-detects Next.js and builds it. You just need to set the correct API URL environment variable on each.
- Go back to the Vercel dashboard and click Add New... > Project.
- Import the agence-amour-marketing repository.
- In Environment Variables, add:
NEXT_PUBLIC_API_URL=https://agence-amour-api.vercel.app
- Click Deploy and wait for the build to complete.
- Copy the live URL for the marketing site.
- Repeat the same process for agence-amour-dashboard: import the repo, add the same NEXT_PUBLIC_API_URL environment variable, and deploy.
- Copy the dashboard's live URL.
Why the same environment variable for both?
Both the marketing site and the dashboard talk to the same API. The NEXT_PUBLIC_ prefix makes the variable available in browser-side code (required for client-side fetch calls). Both apps already use process.env.NEXT_PUBLIC_API_URL in their code.
- Now go back to your API project settings on Vercel.
- Click Settings > Environment Variables.
- Update DASHBOARD_URL and FRONTEND_URL with the actual Vercel URLs you just copied.
- Redeploy the API by clicking Deployments > Redeploy on the latest deployment.
Test the full deployed flow.
- Open your marketing site URL and navigate to the sign-up page.
- Submit a test registration with any data.
- Open your dashboard URL, log in, and check the Registrations page.
- You should see the new registration appear. Confirm the notification email fired by checking your API's function logs in the Vercel dashboard under Logs.
CORS error when the frontend calls the API?
Your API's CORS configuration likely only allows localhost origins. Open your API's CORS setup (usually in server.ts) and add your Vercel domain(s) to the allowed origins list. Redeploy after the change.
Help me fix CORS errors between my Vercel-deployed frontend and API.
📣 File uploads will not work yet
Vercel ignores express.static(), so uploaded photos and CVs from local development will not render on the live site. For a production-ready solution, you would replace the local Multer disk storage with Vercel Blob (or any cloud storage). For this demo to your clients, the registration data, appointments, and email flows all work. The file upload migration is a follow-up task.
All three apps are live. Your clients can now visit the marketing site, sign up, and staff can manage everything from the deployed dashboard. The system works end-to-end on the open internet.
Secret mission
Add Stats and Analytics to the Dashboard Homepage
The dashboard homepage is empty. Staff have no overview of how the agency is performing. Can you build a stats panel that shows registration counts, upcoming appointments, and trends over the past 30 days?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with no paid services. Decide whether to keep your resources running, pause them to come back later, or delete test data entirely.
Resources you used:
- Running dev servers (Marketing on port 3000, Dashboard on port 3001, API on port 4000).
- MongoDB agence-amour database (registrations collection added).
- Uploaded files in agence-amour-api/uploads/ (test photos and CVs).
Keep everything running
No action needed. Choose this if you are continuing to the next project (RDV scheduling system).
- Your three dev servers stay running on ports 3000, 3001, and 4000.
- MongoDB keeps the registrations collection and your test data intact.
- Uploaded test files remain in agence-amour-api/uploads/ for the next project to build on.
Pause - I'll come back to this later
Shut down running processes to free up memory and terminal tabs. Your code and data stay on disk.
- Press Ctrl+C in each of the three terminal tabs running your dev servers.
- Stop MongoDB by running:
/usr/local/mongodb/bin/mongod --shutdown --dbpath ~/mongodb-data
When you return, restart MongoDB and relaunch the dev servers in each repo.
Delete - I don't want to use this again
Remove test registration data and uploaded files without affecting the rest of your project code.
- Clear the registrations collection by opening mongosh and running:
mongosh
use agence-amour
db.registrations.deleteMany({})
- Delete uploaded test files (photos and CVs) by running:
rm -rf agence-amour-api/uploads/*
- Press Ctrl+C in each terminal to stop the dev servers.
- Stop MongoDB by running:
/usr/local/mongodb/bin/mongod --shutdown --dbpath ~/mongodb-data
Nice Work!
Nice Work!
Nice work! You have built a complete pre-registration system where visitors can sign up with their full profile, staff get notified instantly, appointments are scheduled automatically with personalized emails, and the whole thing is live on the internet for your clients to see.
You learned how to:
- Build a multi-section sign-up form that collects personal details, affective profile, three required file uploads (passport photo, body photo, CV), and a QR code payment option with postpone functionality.
- Create a full appointments system with CRUD routes restricted by clearance level, date-analysis logic that suggests visit dates, and automated Nodemailer emails confirming appointment details to each user.
- Deploy all three repositories to Vercel with a free MongoDB Atlas cluster, configuring environment variables so the system runs end-to-end on live URLs your clients can visit.
- Secret Mission: Built a stats and analytics panel on the dashboard homepage with registration counts, appointment trends, and upcoming visits for the week.
Ready to quiz yourself?