# Visity — Implementation Guide
> Event Management Platform | Node.js Backend · React + Capacitor Frontend

---

## Table of Contents

1. [Tech Stack Decision](#1-tech-stack-decision)
2. [Project Structure](#2-project-structure)
3. [Database Schema](#3-database-schema)
4. [Backend Implementation (Node.js)](#4-backend-implementation-nodejs)
   - 4.1 Setup & Config
   - 4.2 Authentication API
   - 4.3 Events API
   - 4.4 Attendees API
   - 4.5 Scanner / Check-in API
   - 4.6 Categories API
   - 4.7 Email API
   - 4.8 Photo Gallery API
   - 4.9 Document QR API
   - 4.10 Payment Settings API
   - 4.11 RSVP Form API
5. [Frontend Implementation (React + Capacitor)](#5-frontend-implementation-react--capacitor)
   - 5.1 Setup
   - 5.2 Route Map
   - 5.3 Auth — Login / OTP
   - 5.4 Dashboard
   - 5.5 Create / Edit Event
   - 5.6 Event Summary
   - 5.7 Attendees
   - 5.8 Scanner History
   - 5.9 Ticket Categories
   - 5.10 Email Module
   - 5.11 Photo Gallery
   - 5.12 Document QR Generator
   - 5.13 Public RSVP Form
   - 5.14 Scanner Interface
   - 5.15 Kiosk Interface
   - 5.16 Scanner Checkpoints
6. [Mobile (Capacitor) Setup](#6-mobile-capacitor-setup)
7. [Environment Variables](#7-environment-variables)
8. [Implementation Priority Order](#8-implementation-priority-order)

---

## 1. Tech Stack Decision

### Why React (Vite) + Capacitor?

| Concern | Solution |
|---|---|
| Web app | React 18 + Vite + Tailwind CSS |
| iOS app | Capacitor wraps the React build as a native iOS app |
| Android app | Capacitor wraps the React build as a native Android app |
| Native APIs (camera for QR scan) | Capacitor plugins (`@capacitor-community/barcode-scanner`) |
| Single codebase | ✅ One React app runs everywhere |
| Backend | Node.js + Express + Prisma ORM |
| Database | PostgreSQL |
| Auth | Email OTP (Nodemailer) + JWT sessions |
| File Storage | AWS S3 or Cloudflare R2 |
| Email Delivery | SendGrid or Resend |
| WhatsApp | Twilio or WATI API |
| QR Code Generation | `qrcode` npm package |
| Real-time (check-in updates) | Socket.io |

### Full Stack at a Glance

```
visity/
├── apps/
│   ├── api/          ← Node.js + Express backend
│   └── web/          ← React (Vite) frontend → also becomes iOS/Android via Capacitor
└── packages/
    └── shared/       ← Shared TypeScript types
```

---

## 2. Project Structure

### Backend (`apps/api/`)

```
apps/api/
├── src/
│   ├── config/
│   │   ├── database.ts        ← Prisma client
│   │   ├── mailer.ts          ← Nodemailer / SendGrid setup
│   │   ├── s3.ts              ← AWS S3 / R2 client
│   │   └── whatsapp.ts        ← Twilio / WATI client
│   ├── middleware/
│   │   ├── auth.ts            ← JWT verification middleware
│   │   ├── upload.ts          ← Multer file upload middleware
│   │   └── errorHandler.ts
│   ├── routes/
│   │   ├── auth.routes.ts
│   │   ├── events.routes.ts
│   │   ├── attendees.routes.ts
│   │   ├── scanner.routes.ts
│   │   ├── categories.routes.ts
│   │   ├── email.routes.ts
│   │   ├── gallery.routes.ts
│   │   ├── documents.routes.ts
│   │   ├── payment.routes.ts
│   │   └── rsvp.routes.ts     ← Public, no auth
│   ├── controllers/           ← One controller per route file
│   ├── services/              ← Business logic layer
│   ├── utils/
│   │   ├── qrcode.ts          ← QR generation helpers
│   │   ├── otp.ts             ← OTP generation + expiry
│   │   └── nanoid.ts          ← Short unique ID generator
│   ├── prisma/
│   │   └── schema.prisma
│   └── server.ts              ← Entry point
├── .env
└── package.json
```

### Frontend (`apps/web/`)

```
apps/web/
├── src/
│   ├── api/                   ← Axios API client functions (one file per domain)
│   │   ├── auth.api.ts
│   │   ├── events.api.ts
│   │   ├── attendees.api.ts
│   │   ├── scanner.api.ts
│   │   ├── email.api.ts
│   │   ├── gallery.api.ts
│   │   └── rsvp.api.ts
│   ├── components/
│   │   ├── ui/                ← Reusable: Button, Input, Badge, Modal, Table, etc.
│   │   ├── layout/            ← AppShell, Sidebar, Header, BottomNav (mobile)
│   │   └── shared/            ← EventCard, AttendeeRow, QRDisplay, etc.
│   ├── pages/
│   │   ├── auth/
│   │   │   ├── LoginPage.tsx
│   │   │   └── OtpPage.tsx
│   │   ├── dashboard/
│   │   │   └── DashboardPage.tsx
│   │   ├── events/
│   │   │   ├── CreateEventPage.tsx
│   │   │   ├── EditEventPage.tsx
│   │   │   └── [eventId]/
│   │   │       ├── SummaryPage.tsx
│   │   │       ├── AttendeesPage.tsx
│   │   │       ├── ScannerHistoryPage.tsx
│   │   │       ├── CategoriesPage.tsx
│   │   │       ├── email/
│   │   │       │   ├── TemplatesPage.tsx
│   │   │       │   ├── BroadcastPage.tsx
│   │   │       │   ├── DeliveryReportPage.tsx
│   │   │       │   └── EmailSetupPage.tsx
│   │   │       ├── GalleryPage.tsx
│   │   │       └── DocumentQrPage.tsx
│   │   ├── scanner/
│   │   │   ├── ScannerLoginPage.tsx
│   │   │   └── ScannerDashboard.tsx
│   │   ├── kiosk/
│   │   │   ├── KioskLoginPage.tsx
│   │   │   └── KioskScanPage.tsx
│   │   └── public/
│   │       └── RsvpFormPage.tsx   ← No auth required
│   ├── store/                 ← Zustand stores
│   │   ├── auth.store.ts
│   │   └── event.store.ts
│   ├── hooks/                 ← Custom React hooks
│   ├── types/                 ← TypeScript interfaces
│   ├── lib/
│   │   └── axios.ts           ← Configured Axios instance
│   ├── App.tsx
│   ├── routes.tsx             ← React Router v6 route definitions
│   └── main.tsx
├── capacitor.config.ts        ← Capacitor config
├── vite.config.ts
├── tailwind.config.ts
└── package.json
```

---

## 3. Database Schema

```prisma
// apps/api/src/prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ─── AUTH ────────────────────────────────────────────────────────────────────

model Organizer {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  events       Event[]
  checkpoints  Checkpoint[]
  plan         Plan?
}

model OtpToken {
  id        String   @id @default(cuid())
  email     String
  otp       String
  expiresAt DateTime
  used      Boolean  @default(false)
  createdAt DateTime @default(now())

  @@index([email])
}

model Plan {
  id               String   @id @default(cuid())
  organizerId      String   @unique
  organizer        Organizer @relation(fields: [organizerId], references: [id])
  name             String   @default("Free")
  totalCredits     Int      @default(50)
  usedCredits      Int      @default(0)
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

// ─── EVENTS ──────────────────────────────────────────────────────────────────

model Event {
  id                 String   @id @default(cuid())
  organizerId        String
  organizer          Organizer @relation(fields: [organizerId], references: [id])

  name               String
  description        String?
  startDate          DateTime
  endDate            DateTime?
  location           String?
  locationLat        Float?
  locationLng        Float?
  isPaid             Boolean  @default(false)
  requireApproval    Boolean  @default(false)
  sendEmailNotif     Boolean  @default(true)
  sendWhatsappNotif  Boolean  @default(true)
  bannerUrl          String?
  fromName           String?
  fromEmail          String?

  createdAt          DateTime @default(now())
  updatedAt          DateTime @updatedAt

  attendees          Attendee[]
  categories         TicketCategory[]
  checkpoints        Checkpoint[]
  scanLogs           ScanLog[]
  emailBroadcasts    EmailBroadcast[]
  photoCategories    PhotoCategory[]
  documents          EventDocument[]
}

// ─── TICKET CATEGORIES ────────────────────────────────────────────────────────

model TicketCategory {
  id               String   @id @default(cuid())
  eventId          String
  event            Event    @relation(fields: [eventId], references: [id], onDelete: Cascade)
  name             String   @default("General")
  price            Float    @default(0)
  triggerIdOnScan  Boolean  @default(false)
  templateUrl      String?  // Background image URL for ID card design
  order            Int      @default(0)
  createdAt        DateTime @default(now())

  attendees        Attendee[]
}

// ─── ATTENDEES ────────────────────────────────────────────────────────────────

model Attendee {
  id              String   @id @default(cuid())
  shortId         String   @unique  // Alphanumeric short ID shown to organizer
  eventId         String
  event           Event    @relation(fields: [eventId], references: [id], onDelete: Cascade)
  categoryId      String?
  category        TicketCategory? @relation(fields: [categoryId], references: [id])

  name            String
  email           String?
  mobile          String
  countryCode     String   @default("+91")
  companyName     String?
  jobTitle        String?
  numberOfGuests  Int      @default(1)
  additionalInfo  Json?    // Stores custom field values

  status          AttendeeStatus @default(PENDING)
  attendance      AttendanceStatus @default(NOT_ARRIVED)
  source          String   @default("direct") // direct | csv | admin | webhook
  affiliateCode   String?

  qrCodeUrl       String?  // URL of generated QR code image
  idCardUrl       String?  // Generated ID card / access pass URL

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  scanLogs        ScanLog[]
}

enum AttendeeStatus {
  PENDING
  APPROVED
  REJECTED
}

enum AttendanceStatus {
  NOT_ARRIVED
  ARRIVED
}

// ─── CHECKPOINTS ─────────────────────────────────────────────────────────────

model Checkpoint {
  id           String   @id @default(cuid())
  eventId      String
  event        Event    @relation(fields: [eventId], references: [id], onDelete: Cascade)
  organizerId  String
  organizer    Organizer @relation(fields: [organizerId], references: [id])

  name         String   @default("Registration")
  personName   String
  mobile       String
  passwordHash String
  isActive     Boolean  @default(true)
  type         String   @default("Event Specific")
  categories   String[] // Array of category IDs this checkpoint scans

  createdAt    DateTime @default(now())
  updatedAt    DateTime @updatedAt

  scanLogs     ScanLog[]
}

// ─── SCAN LOGS ────────────────────────────────────────────────────────────────

model ScanLog {
  id           String   @id @default(cuid())
  eventId      String
  event        Event    @relation(fields: [eventId], references: [id])
  attendeeId   String
  attendee     Attendee @relation(fields: [attendeeId], references: [id])
  checkpointId String?
  checkpoint   Checkpoint? @relation(fields: [checkpointId], references: [id])

  scanMethod   ScanMethod @default(QR)
  scannedAt    DateTime   @default(now())
}

enum ScanMethod {
  QR
  MANUAL
}

// ─── EMAIL ────────────────────────────────────────────────────────────────────

model EmailTemplate {
  id          String   @id @default(cuid())
  eventId     String
  name        String
  subject     String
  body        String   // HTML body with {name} and {QRCode} merge tags
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  broadcasts  EmailBroadcast[]
}

model EmailBroadcast {
  id           String   @id @default(cuid())
  eventId      String
  event        Event    @relation(fields: [eventId], references: [id])
  templateId   String?
  template     EmailTemplate? @relation(fields: [templateId], references: [id])

  fromName     String
  fromEmail    String
  subject      String
  body         String

  totalSent    Int @default(0)
  delivered    Int @default(0)
  opened       Int @default(0)
  failed       Int @default(0)

  sentAt       DateTime?
  createdAt    DateTime @default(now())
}

// ─── PHOTO GALLERY ────────────────────────────────────────────────────────────

model PhotoCategory {
  id        String   @id @default(cuid())
  eventId   String
  event     Event    @relation(fields: [eventId], references: [id], onDelete: Cascade)
  name      String
  createdAt DateTime @default(now())

  photos    Photo[]
}

model Photo {
  id         String        @id @default(cuid())
  categoryId String
  category   PhotoCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  url        String
  fileName   String
  size       Int
  uploadedAt DateTime @default(now())
}

// ─── DOCUMENT QR ──────────────────────────────────────────────────────────────

model EventDocument {
  id        String   @id @default(cuid())
  eventId   String
  event     Event    @relation(fields: [eventId], references: [id], onDelete: Cascade)
  name      String
  url       String   // S3 URL
  qrCodeUrl String   // Generated QR pointing to the document URL
  uploadedAt DateTime @default(now())
}
```

---

## 4. Backend Implementation (Node.js)

### 4.1 Setup & Config

**Install dependencies:**

```bash
mkdir apps/api && cd apps/api
npm init -y
npm install express cors helmet morgan dotenv
npm install prisma @prisma/client
npm install jsonwebtoken bcryptjs nodemailer
npm install multer @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
npm install qrcode nanoid socket.io
npm install -D typescript @types/express @types/node ts-node nodemon
npx prisma init
```

**`src/server.ts`:**

```typescript
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { createServer } from 'http';
import { Server } from 'socket.io';
import authRoutes from './routes/auth.routes';
import eventRoutes from './routes/events.routes';
import attendeeRoutes from './routes/attendees.routes';
import scannerRoutes from './routes/scanner.routes';
import categoryRoutes from './routes/categories.routes';
import emailRoutes from './routes/email.routes';
import galleryRoutes from './routes/gallery.routes';
import documentRoutes from './routes/documents.routes';
import rsvpRoutes from './routes/rsvp.routes';
import paymentRoutes from './routes/payment.routes';
import { errorHandler } from './middleware/errorHandler';

const app = express();
const httpServer = createServer(app);

export const io = new Server(httpServer, {
  cors: { origin: process.env.FRONTEND_URL, credentials: true }
});

app.use(helmet());
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
app.use(express.json());

// Routes
app.use('/api/auth',       authRoutes);
app.use('/api/events',     eventRoutes);
app.use('/api/attendees',  attendeeRoutes);
app.use('/api/scanner',    scannerRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/email',      emailRoutes);
app.use('/api/gallery',    galleryRoutes);
app.use('/api/documents',  documentRoutes);
app.use('/api/payment',    paymentRoutes);
app.use('/api/rsvp',       rsvpRoutes);  // Public

app.use(errorHandler);

httpServer.listen(process.env.PORT || 4000, () => {
  console.log(`API running on port ${process.env.PORT || 4000}`);
});
```

**`src/middleware/auth.ts`:**

```typescript
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';

export interface AuthRequest extends Request {
  organizerId?: string;
}

export const requireAuth = (req: AuthRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!) as { organizerId: string };
    req.organizerId = payload.organizerId;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
};
```

---

### 4.2 Authentication API

**Routes:** `POST /api/auth/send-otp` · `POST /api/auth/verify-otp`

```typescript
// src/routes/auth.routes.ts
import { Router } from 'express';
import { sendOtp, verifyOtp } from '../controllers/auth.controller';

const router = Router();
router.post('/send-otp',   sendOtp);
router.post('/verify-otp', verifyOtp);
export default router;
```

```typescript
// src/controllers/auth.controller.ts
import { Request, Response } from 'express';
import { prisma } from '../config/database';
import { generateOtp } from '../utils/otp';
import { sendOtpEmail } from '../config/mailer';
import jwt from 'jsonwebtoken';
import { addMinutes, isPast } from 'date-fns';

export const sendOtp = async (req: Request, res: Response) => {
  const { email } = req.body;
  if (!email) return res.status(400).json({ error: 'Email is required' });

  const otp = generateOtp();  // 6-digit number
  const expiresAt = addMinutes(new Date(), 10);

  // Invalidate old OTPs for this email
  await prisma.otpToken.updateMany({
    where: { email, used: false },
    data: { used: true }
  });

  await prisma.otpToken.create({ data: { email, otp, expiresAt } });
  await sendOtpEmail(email, otp);

  res.json({ message: 'OTP sent successfully' });
};

export const verifyOtp = async (req: Request, res: Response) => {
  const { email, otp } = req.body;

  const record = await prisma.otpToken.findFirst({
    where: { email, otp, used: false },
    orderBy: { createdAt: 'desc' }
  });

  if (!record || isPast(record.expiresAt)) {
    return res.status(401).json({ error: 'Invalid or expired OTP' });
  }

  await prisma.otpToken.update({ where: { id: record.id }, data: { used: true } });

  // Create or find organizer
  let organizer = await prisma.organizer.findUnique({ where: { email } });
  if (!organizer) {
    organizer = await prisma.organizer.create({ data: { email } });
    // Create default free plan
    await prisma.plan.create({
      data: { organizerId: organizer.id, name: 'Free', totalCredits: 50 }
    });
  }

  const token = jwt.sign({ organizerId: organizer.id }, process.env.JWT_SECRET!, {
    expiresIn: '30d'
  });

  res.json({ token, organizer });
};
```

---

### 4.3 Events API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET | `/api/events` | List organizer's events |
| POST | `/api/events` | Create event |
| GET | `/api/events/:id` | Get single event |
| PUT | `/api/events/:id` | Update event |
| DELETE | `/api/events/:id` | Delete event |
| GET | `/api/events/:id/summary` | Summary stats (RSVPs, arrived, not arrived) |

```typescript
// src/routes/events.routes.ts
import { Router } from 'express';
import { requireAuth } from '../middleware/auth';
import * as ctrl from '../controllers/events.controller';

const router = Router();
router.use(requireAuth);

router.get('/',          ctrl.listEvents);
router.post('/',         ctrl.createEvent);
router.get('/:id',       ctrl.getEvent);
router.put('/:id',       ctrl.updateEvent);
router.delete('/:id',    ctrl.deleteEvent);
router.get('/:id/summary', ctrl.getEventSummary);

export default router;
```

```typescript
// src/controllers/events.controller.ts (key methods)
import { AuthRequest } from '../middleware/auth';
import { Response } from 'express';
import { prisma } from '../config/database';

export const listEvents = async (req: AuthRequest, res: Response) => {
  const events = await prisma.event.findMany({
    where: { organizerId: req.organizerId! },
    include: { _count: { select: { attendees: true } } },
    orderBy: { createdAt: 'desc' }
  });
  res.json(events);
};

export const createEvent = async (req: AuthRequest, res: Response) => {
  const {
    name, description, startDate, endDate, location, locationLat, locationLng,
    isPaid, requireApproval, sendEmailNotif, sendWhatsappNotif
  } = req.body;

  const event = await prisma.event.create({
    data: {
      organizerId: req.organizerId!,
      name, description, startDate: new Date(startDate),
      endDate: endDate ? new Date(endDate) : null,
      location, locationLat, locationLng,
      isPaid: isPaid ?? false,
      requireApproval: requireApproval ?? false,
      sendEmailNotif: sendEmailNotif ?? true,
      sendWhatsappNotif: sendWhatsappNotif ?? true
    }
  });

  // Create default "General" ticket category
  await prisma.ticketCategory.create({
    data: { eventId: event.id, name: 'General', price: 0, order: 0 }
  });

  res.status(201).json(event);
};

export const getEventSummary = async (req: AuthRequest, res: Response) => {
  const { id } = req.params;

  const [totalRsvps, arrived, categoryBreakdown] = await Promise.all([
    prisma.attendee.count({ where: { eventId: id } }),
    prisma.attendee.count({ where: { eventId: id, attendance: 'ARRIVED' } }),
    prisma.attendee.groupBy({
      by: ['categoryId'],
      where: { eventId: id },
      _count: true
    })
  ]);

  res.json({
    totalRsvps,
    arrived,
    notArrived: totalRsvps - arrived,
    categoryBreakdown
  });
};
```

---

### 4.4 Attendees API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET | `/api/events/:eventId/attendees` | List with filters + pagination |
| POST | `/api/events/:eventId/attendees` | Add single attendee |
| PUT | `/api/events/:eventId/attendees/:id` | Update attendee (approve/reject/edit) |
| DELETE | `/api/events/:eventId/attendees/:id` | Remove attendee |
| POST | `/api/events/:eventId/attendees/import` | Bulk CSV import |
| GET | `/api/events/:eventId/attendees/export` | Download CSV |
| POST | `/api/events/:eventId/attendees/bulk-notify` | Send email/WhatsApp to selection |

```typescript
// Key controller methods
import { nanoid } from 'nanoid';
import QRCode from 'qrcode';
import { uploadToS3 } from '../config/s3';

export const addAttendee = async (req: AuthRequest, res: Response) => {
  const { eventId } = req.params;
  const { name, email, mobile, categoryId, numberOfGuests, companyName, jobTitle } = req.body;

  const event = await prisma.event.findUnique({ where: { id: eventId } });
  if (!event) return res.status(404).json({ error: 'Event not found' });

  const shortId = nanoid(10).toUpperCase();
  const status = event.requireApproval ? 'PENDING' : 'APPROVED';

  const attendee = await prisma.attendee.create({
    data: {
      shortId, eventId, categoryId, name, email, mobile,
      numberOfGuests: numberOfGuests ?? 1,
      companyName, jobTitle, status, source: 'admin'
    }
  });

  // Generate QR code containing attendee's shortId
  const qrDataUrl = await QRCode.toDataURL(attendee.shortId, { width: 300 });
  const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
  const qrCodeUrl = await uploadToS3(qrBuffer, `qr/${attendee.shortId}.png`, 'image/png');

  const updated = await prisma.attendee.update({
    where: { id: attendee.id }, data: { qrCodeUrl }
  });

  // Send email/WhatsApp confirmation if event settings allow
  if (event.sendEmailNotif && email) {
    await sendConfirmationEmail(event, updated);
  }
  if (event.sendWhatsappNotif && mobile) {
    await sendWhatsappConfirmation(event, updated);
  }

  // Deduct credit
  await prisma.plan.updateMany({
    where: { organizerId: event.organizerId },
    data: { usedCredits: { increment: numberOfGuests ?? 1 } }
  });

  res.status(201).json(updated);
};

export const importCsv = async (req: AuthRequest, res: Response) => {
  // req.file = uploaded CSV via multer
  // Parse CSV, validate rows, create attendees in bulk
  // Return { created: N, skipped: N, errors: [] }
};

export const exportCsv = async (req: AuthRequest, res: Response) => {
  const { eventId } = req.params;
  const attendees = await prisma.attendee.findMany({
    where: { eventId },
    include: { category: true }
  });

  const csvRows = [
    ['ID', 'Name', 'Mobile', 'Email', 'Company', 'Job Title', 'Category', 'Status', 'Attendance', 'Created At'],
    ...attendees.map(a => [
      a.shortId, a.name, a.mobile, a.email ?? '', a.companyName ?? '',
      a.jobTitle ?? '', a.category?.name ?? '', a.status, a.attendance,
      a.createdAt.toISOString()
    ])
  ];

  const csv = csvRows.map(row => row.join(',')).join('\n');
  res.setHeader('Content-Type', 'text/csv');
  res.setHeader('Content-Disposition', 'attachment; filename=attendees.csv');
  res.send(csv);
};
```

---

### 4.5 Scanner / Check-in API

**Routes:**

| Method | Path | Description | Auth |
|---|---|---|---|
| POST | `/api/scanner/login` | Checkpoint operator login | None |
| POST | `/api/scanner/checkin` | Scan QR / mark arrived | Checkpoint JWT |
| GET | `/api/scanner/attendee/:shortId` | Lookup attendee by ID or name | Checkpoint JWT |
| GET | `/api/events/:eventId/scanner-history` | Full scan log | Organizer JWT |

```typescript
// src/controllers/scanner.controller.ts

export const checkpointLogin = async (req: Request, res: Response) => {
  const { mobile, password } = req.body;

  const checkpoint = await prisma.checkpoint.findFirst({
    where: { mobile, isActive: true },
    include: { event: true }
  });

  if (!checkpoint || !bcrypt.compareSync(password, checkpoint.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  const token = jwt.sign(
    { checkpointId: checkpoint.id, eventId: checkpoint.eventId },
    process.env.JWT_SECRET!,
    { expiresIn: '12h' }
  );

  res.json({ token, checkpoint });
};

export const checkIn = async (req: Request, res: Response) => {
  // req.checkpointId, req.eventId from checkpoint JWT middleware
  const { shortId, method = 'QR' } = req.body;

  const attendee = await prisma.attendee.findUnique({
    where: { shortId },
    include: { event: true }
  });

  if (!attendee) return res.status(404).json({ error: 'Attendee not found' });
  if (attendee.eventId !== req.body.eventId) {
    return res.status(403).json({ error: 'Attendee is for a different event' });
  }
  if (attendee.status !== 'APPROVED') {
    return res.status(400).json({ error: 'Attendee is not approved' });
  }

  // Mark arrived
  const updated = await prisma.attendee.update({
    where: { shortId },
    data: { attendance: 'ARRIVED' }
  });

  // Log the scan
  await prisma.scanLog.create({
    data: {
      eventId: attendee.eventId,
      attendeeId: attendee.id,
      checkpointId: req.body.checkpointId,
      scanMethod: method
    }
  });

  // Emit real-time update to organizer dashboard
  io.to(`event:${attendee.eventId}`).emit('attendee:checkin', {
    attendeeId: attendee.id, shortId, name: attendee.name
  });

  // Trigger ID card send if category has it enabled
  if (attendee.category?.triggerIdOnScan) {
    await sendIdCardOnCheckin(attendee);
  }

  res.json({ success: true, attendee: updated });
};
```

---

### 4.6 Categories API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET | `/api/events/:eventId/categories` | List categories |
| POST | `/api/events/:eventId/categories` | Create category |
| PUT | `/api/events/:eventId/categories/:id` | Update (name, price, toggle, order) |
| DELETE | `/api/events/:eventId/categories/:id` | Delete category |
| PUT | `/api/events/:eventId/categories/:id/template` | Upload template background image |
| PUT | `/api/events/:eventId/categories/reorder` | Bulk reorder |

```typescript
export const updateTemplate = async (req: AuthRequest, res: Response) => {
  // req.file = uploaded background image via multer
  const { id } = req.params;
  const imageUrl = await uploadToS3(req.file!.buffer, `templates/${id}.png`, 'image/png');

  const category = await prisma.ticketCategory.update({
    where: { id }, data: { templateUrl: imageUrl }
  });

  res.json(category);
};
```

---

### 4.7 Email API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET/POST/PUT/DELETE | `/api/events/:id/email/templates` | CRUD email templates |
| POST | `/api/events/:id/email/broadcast` | Send broadcast email |
| GET | `/api/events/:id/email/delivery-report` | Broadcast stats |
| PUT | `/api/events/:id/email/setup` | Update from name, banner |
| POST | `/api/events/:id/email/test` | Send test email |

```typescript
export const sendBroadcast = async (req: AuthRequest, res: Response) => {
  const { eventId } = req.params;
  const { subject, body, fromName, fromEmail } = req.body;

  const attendees = await prisma.attendee.findMany({
    where: { eventId, status: 'APPROVED', email: { not: null } }
  });

  const broadcast = await prisma.emailBroadcast.create({
    data: { eventId, subject, body, fromName, fromEmail, totalSent: attendees.length }
  });

  // Queue emails asynchronously (use Bull queue or simple Promise.all in batches)
  sendBroadcastEmails(attendees, broadcast);

  res.json({ message: `Broadcast queued for ${attendees.length} recipients`, broadcastId: broadcast.id });
};

// In service: replace merge tags and send per attendee
const sendBroadcastEmails = async (attendees, broadcast) => {
  for (const attendee of attendees) {
    const personalBody = broadcast.body
      .replace(/\{name\}/g, attendee.name)
      .replace(/\{QRCode\}/g, `<img src="${attendee.qrCodeUrl}" width="200" />`);

    await mailer.send({
      to: attendee.email,
      from: { name: broadcast.fromName, email: broadcast.fromEmail },
      subject: broadcast.subject.replace(/\{name\}/g, attendee.name),
      html: personalBody
    });
  }
};
```

---

### 4.8 Photo Gallery API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET | `/api/events/:id/gallery` | List categories + photos |
| POST | `/api/events/:id/gallery/categories` | Create photo category |
| POST | `/api/events/:id/gallery/categories/:catId/photos` | Upload photos |
| DELETE | `/api/events/:id/gallery/photos/:photoId` | Delete photo |
| GET | `/api/public/gallery/:eventId` | Public gallery (no auth) |

---

### 4.9 Document QR API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET | `/api/events/:id/documents` | List documents + their QR URLs |
| POST | `/api/events/:id/documents` | Upload document, auto-generate QR |
| DELETE | `/api/events/:id/documents/:docId` | Delete document |

```typescript
export const uploadDocument = async (req: AuthRequest, res: Response) => {
  const { eventId } = req.params;
  const docUrl = await uploadToS3(req.file!.buffer, `docs/${eventId}/${Date.now()}.pdf`, 'application/pdf');

  // Generate QR pointing to the document URL
  const qrDataUrl = await QRCode.toDataURL(docUrl, { width: 300 });
  const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
  const qrCodeUrl = await uploadToS3(qrBuffer, `doc-qr/${eventId}/${Date.now()}.png`, 'image/png');

  const doc = await prisma.eventDocument.create({
    data: { eventId, name: req.file!.originalname, url: docUrl, qrCodeUrl }
  });

  res.status(201).json(doc);
};
```

---

### 4.10 Payment Settings API

**Routes:**

| Method | Path | Description |
|---|---|---|
| GET | `/api/payment/config` | Get organizer's gateway config status |
| POST | `/api/payment/request-setup` | Send request to Visity team |

```typescript
// Simple — payment gateway is team-configured, not self-serve
export const requestSetup = async (req: AuthRequest, res: Response) => {
  // Send internal email/Slack notification to Visity team
  await notifyTeam(req.organizerId!);
  res.json({ message: 'Our team will reach out to configure your payment gateway.' });
};
```

---

### 4.11 RSVP Form API (Public)

**Routes (no auth required):**

| Method | Path | Description |
|---|---|---|
| GET | `/api/rsvp/:eventId` | Get event details for RSVP form |
| POST | `/api/rsvp/:eventId` | Submit RSVP |

```typescript
// src/routes/rsvp.routes.ts — NO requireAuth middleware
export const getRsvpEvent = async (req: Request, res: Response) => {
  const { eventId } = req.params;
  const event = await prisma.event.findUnique({
    where: { id: eventId },
    include: { categories: { orderBy: { order: 'asc' } } },
    select: {
      id: true, name: true, description: true, startDate: true, endDate: true,
      location: true, locationLat: true, locationLng: true,
      bannerUrl: true, isPaid: true, categories: true
    }
  });
  if (!event) return res.status(404).json({ error: 'Event not found' });
  res.json(event);
};

export const submitRsvp = async (req: Request, res: Response) => {
  const { eventId } = req.params;
  const { name, email, mobile, categoryId, numberOfGuests } = req.body;

  const event = await prisma.event.findUnique({ where: { id: eventId } });
  if (!event) return res.status(404).json({ error: 'Event not found' });

  // Check plan credits
  const plan = await prisma.plan.findUnique({ where: { organizerId: event.organizerId } });
  if (plan && (plan.usedCredits + (numberOfGuests ?? 1)) > plan.totalCredits) {
    return res.status(402).json({ error: 'Event registrations are full' });
  }

  const shortId = nanoid(10).toUpperCase();
  const status = event.requireApproval ? 'PENDING' : 'APPROVED';

  const attendee = await prisma.attendee.create({
    data: { shortId, eventId, categoryId, name, email, mobile, numberOfGuests, status, source: 'direct' }
  });

  // Generate & store QR
  const qrDataUrl = await QRCode.toDataURL(shortId, { width: 300 });
  const qrBuffer = Buffer.from(qrDataUrl.split(',')[1], 'base64');
  const qrCodeUrl = await uploadToS3(qrBuffer, `qr/${shortId}.png`, 'image/png');
  await prisma.attendee.update({ where: { id: attendee.id }, data: { qrCodeUrl } });

  // Notifications
  if (event.sendEmailNotif && email) await sendConfirmationEmail(event, { ...attendee, qrCodeUrl });
  if (event.sendWhatsappNotif && mobile) await sendWhatsappConfirmation(event, { ...attendee, qrCodeUrl });

  // Deduct credits
  await prisma.plan.updateMany({
    where: { organizerId: event.organizerId },
    data: { usedCredits: { increment: numberOfGuests ?? 1 } }
  });

  res.status(201).json({ message: 'RSVP submitted successfully', shortId });
};
```

---

## 5. Frontend Implementation (React + Capacitor)

### 5.1 Setup

```bash
npm create vite@latest apps/web -- --template react-ts
cd apps/web
npm install
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
npm install react-router-dom@6 axios zustand
npm install react-hook-form zod @hookform/resolvers
npm install react-hot-toast lucide-react
npm install @tanstack/react-query
npm install qrcode.react
npm install @capacitor/core @capacitor/cli
npx cap init "Visity" "io.visity.app" --web-dir dist
npm install @capacitor/ios @capacitor/android
npm install @capacitor-community/barcode-scanner  # QR scanner for native
```

**`src/lib/axios.ts`:**

```typescript
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  withCredentials: true,
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

api.interceptors.response.use(
  (res) => res,
  (err) => {
    if (err.response?.status === 401) {
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    return Promise.reject(err);
  }
);

export default api;
```

---

### 5.2 Route Map

```typescript
// src/routes.tsx
import { createBrowserRouter } from 'react-router-dom';

export const router = createBrowserRouter([
  // Public routes (no auth)
  { path: '/login',         element: <LoginPage /> },
  { path: '/login/otp',     element: <OtpPage /> },
  { path: '/rsvp/:eventId', element: <RsvpFormPage /> },

  // Scanner / Kiosk (separate auth)
  { path: '/scanner/login',  element: <ScannerLoginPage /> },
  { path: '/scanner',        element: <ScannerDashboard /> },
  { path: '/kiosk/login',    element: <KioskLoginPage /> },
  { path: '/kiosk',          element: <KioskScanPage /> },

  // Organizer app (requires organizer auth)
  {
    path: '/',
    element: <AppShell />,  // Has sidebar + header
    children: [
      { index: true,           element: <DashboardPage /> },
      { path: 'events/create', element: <CreateEventPage /> },
      { path: 'events/:id/edit', element: <EditEventPage /> },
      {
        path: 'events/:id',
        element: <EventLayout />,  // Has event sidebar
        children: [
          { index: true,                    element: <SummaryPage /> },
          { path: 'attendees',              element: <AttendeesPage /> },
          { path: 'scanner-history',        element: <ScannerHistoryPage /> },
          { path: 'categories',             element: <CategoriesPage /> },
          { path: 'email/templates',        element: <TemplatesPage /> },
          { path: 'email/broadcast',        element: <BroadcastPage /> },
          { path: 'email/delivery-report',  element: <DeliveryReportPage /> },
          { path: 'email/setup',            element: <EmailSetupPage /> },
          { path: 'gallery',                element: <GalleryPage /> },
          { path: 'documents',              element: <DocumentQrPage /> },
        ]
      }
    ]
  }
]);
```

---

### 5.3 Auth — Login / OTP

```typescript
// src/pages/auth/LoginPage.tsx
export default function LoginPage() {
  const [email, setEmail] = useState('');
  const navigate = useNavigate();

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    await api.post('/auth/send-otp', { email });
    // Store email for OTP page
    sessionStorage.setItem('pendingEmail', email);
    navigate('/login/otp');
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="bg-white p-8 rounded-2xl shadow-sm border w-full max-w-md">
        <h1 className="text-2xl font-semibold text-center mb-1">Visity</h1>
        <p className="text-gray-500 text-center text-sm mb-6">Simplified Event Management</p>

        <form onSubmit={handleSubmit} className="space-y-4">
          <div>
            <label className="text-sm font-medium text-gray-700">Email Address</label>
            <input
              type="email" required value={email}
              onChange={e => setEmail(e.target.value)}
              className="mt-1 w-full border border-gray-300 rounded-lg px-3 py-2 text-sm
                         focus:outline-none focus:ring-2 focus:ring-blue-500"
              placeholder="Enter your email address"
            />
          </div>
          <button type="submit"
            className="w-full bg-blue-600 text-white py-2.5 rounded-lg text-sm font-medium
                       hover:bg-blue-700 transition-colors">
            Send OTP
          </button>
        </form>
      </div>
    </div>
  );
}
```

```typescript
// src/pages/auth/OtpPage.tsx
export default function OtpPage() {
  const [otp, setOtp] = useState('');
  const navigate = useNavigate();
  const { setToken } = useAuthStore();
  const email = sessionStorage.getItem('pendingEmail') ?? '';

  const handleVerify = async (e: FormEvent) => {
    e.preventDefault();
    const { data } = await api.post('/auth/verify-otp', { email, otp });
    setToken(data.token);
    localStorage.setItem('token', data.token);
    navigate('/');
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="bg-white p-8 rounded-2xl shadow-sm border w-full max-w-md">
        <h2 className="text-xl font-semibold mb-1">Enter OTP</h2>
        <p className="text-sm text-gray-500 mb-6">Sent to {email}</p>
        <form onSubmit={handleVerify} className="space-y-4">
          <input
            type="text" maxLength={6} value={otp}
            onChange={e => setOtp(e.target.value)}
            className="w-full border rounded-lg px-3 py-2 text-center text-2xl tracking-widest"
            placeholder="000000"
          />
          <button type="submit" className="w-full bg-blue-600 text-white py-2.5 rounded-lg">
            Verify
          </button>
        </form>
      </div>
    </div>
  );
}
```

---

### 5.4 Dashboard

**Key data fetched:**
- `GET /api/events` → event list
- `GET /api/organizer/plan` → credits + plan info

```typescript
// src/pages/dashboard/DashboardPage.tsx
export default function DashboardPage() {
  const { data: events } = useQuery({ queryKey: ['events'], queryFn: () => api.get('/events').then(r => r.data) });
  const { data: plan }   = useQuery({ queryKey: ['plan'],   queryFn: () => api.get('/organizer/plan').then(r => r.data) });

  const totalRsvps = events?.reduce((sum, e) => sum + (e._count?.attendees ?? 0), 0) ?? 0;

  return (
    <div className="p-6 space-y-6">
      <h1 className="text-2xl font-semibold">Dashboard</h1>
      <p className="text-gray-500 text-sm">Manage your events and QR scanner checkpoints</p>

      {/* Stats row */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        <StatCard label="Total Events" value={events?.length ?? 0} />
        <StatCard label="Total RSVPs"  value={totalRsvps} />
        <PlanCard plan={plan} />
      </div>

      {/* Events table */}
      <div className="bg-white rounded-xl border">
        <div className="flex justify-between items-center p-4 border-b">
          <h2 className="font-medium">My Events</h2>
          <div className="flex gap-2">
            <Button onClick={() => navigate('/events/create')}>+ Create Event</Button>
            <Button variant="ghost" onClick={() => window.open('https://ai.visity.io/create')}>
              Create Event with AI ↗
            </Button>
          </div>
        </div>
        <EventsTable events={events ?? []} />
      </div>
    </div>
  );
}
```

---

### 5.5 Create / Edit Event

**Form fields map directly to the API payload:**

```typescript
// src/pages/events/CreateEventPage.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  name:              z.string().min(1, 'Event name is required'),
  startDate:         z.string().min(1, 'Start date is required'),
  startTime:         z.string().min(1, 'Start time is required'),
  endDate:           z.string().optional(),
  endTime:           z.string().optional(),
  description:       z.string().optional(),
  location:          z.string().optional(),
  isPaid:            z.boolean().default(false),
  requireApproval:   z.boolean().default(false),
  sendEmailNotif:    z.boolean().default(true),
  sendWhatsappNotif: z.boolean().default(true),
});

export default function CreateEventPage() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm({ resolver: zodResolver(schema) });
  const navigate = useNavigate();
  const isPaid = watch('isPaid');

  const onSubmit = async (data) => {
    const startDate = new Date(`${data.startDate}T${data.startTime}`).toISOString();
    const endDate   = data.endDate ? new Date(`${data.endDate}T${data.endTime}`).toISOString() : undefined;
    const { data: event } = await api.post('/events', { ...data, startDate, endDate });
    toast.success('Event created!');
    navigate(`/events/${event.id}`);
  };

  return (
    <div className="max-w-2xl mx-auto p-6">
      <h1 className="text-2xl font-semibold mb-1">Create New Event</h1>
      <p className="text-gray-500 text-sm mb-6">Set up your event with all the details</p>

      <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
        {/* Basic Information */}
        <Section title="Basic Information">
          <Field label="Event Name *" error={errors.name?.message}>
            <input {...register('name')} placeholder="Enter event name" />
          </Field>
          <div className="grid grid-cols-2 gap-4">
            <Field label="Start Date *"><input type="date" {...register('startDate')} /></Field>
            <Field label="Start Time *"><input type="time" {...register('startTime')} /></Field>
            <Field label="End Date"><input type="date" {...register('endDate')} /></Field>
            <Field label="End Time"><input type="time" {...register('endTime')} /></Field>
          </div>
          <Field label="Description"><textarea {...register('description')} rows={4} /></Field>
          <Field label="Location"><LocationSearch {...register('location')} /></Field>
        </Section>

        {/* Event Type */}
        <Section title="Event Type">
          <div className="flex gap-4">
            <RadioCard label="Free Event"  value={false} field="isPaid" register={register} />
            <RadioCard label="Paid Event"  value={true}  field="isPaid" register={register} />
          </div>
        </Section>

        {/* Attendee Settings */}
        <Section title="Attendee Settings">
          <Toggle label="Require RSVP Approval" {...register('requireApproval')} />
        </Section>

        {/* Notification Settings */}
        <Section title="Notification Settings">
          <Toggle label="Send Email Notifications"    {...register('sendEmailNotif')} />
          <Toggle label="Send WhatsApp Notifications" {...register('sendWhatsappNotif')} />
        </Section>

        <div className="flex gap-3">
          <Button variant="outline" onClick={() => navigate(-1)}>Cancel</Button>
          <Button type="submit">Create Event</Button>
        </div>
      </form>
    </div>
  );
}
```

---

### 5.6 Event Summary

```typescript
// src/pages/events/[eventId]/SummaryPage.tsx
export default function SummaryPage() {
  const { eventId } = useParams();
  const { data: event }   = useQuery(['event', eventId],   () => api.get(`/events/${eventId}`).then(r => r.data));
  const { data: summary } = useQuery(['summary', eventId], () => api.get(`/events/${eventId}/summary`).then(r => r.data));
  const rsvpUrl = `${window.location.origin}/rsvp/${eventId}`;

  // Real-time: listen for check-in events
  useEffect(() => {
    socket.emit('join', `event:${eventId}`);
    socket.on('attendee:checkin', () => queryClient.invalidateQueries(['summary', eventId]));
    return () => { socket.off('attendee:checkin'); };
  }, [eventId]);

  return (
    <div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
      {/* Event Info */}
      <Card>
        <h2 className="font-semibold text-lg">{event?.name}</h2>
        <p className="text-sm text-gray-500 mt-2">{formatDate(event?.startDate)}</p>
        <p className="text-sm text-gray-500">{formatTime(event?.startDate)}</p>
        <a href={googleMapsUrl(event)} target="_blank" className="text-sm text-blue-600 hover:underline mt-1 block">
          {event?.location} ↗
        </a>
      </Card>

      {/* Summary Stats */}
      <Card>
        <h2 className="font-semibold mb-3">Summary</h2>
        <StatRow label="Total RSVPs" value={summary?.totalRsvps} color="blue" />
        <StatRow label="Arrived"     value={summary?.arrived}    color="green" />
        <StatRow label="Not Arrived" value={summary?.notArrived} color="orange" />
      </Card>

      {/* RSVP Form */}
      <Card>
        <h2 className="font-semibold mb-3">RSVP Form</h2>
        <div className="flex items-center gap-2 bg-gray-50 rounded-lg p-3">
          <span className="text-sm text-gray-600 truncate flex-1">{rsvpUrl}</span>
          <CopyButton text={rsvpUrl} />
          <a href={rsvpUrl} target="_blank"><ExternalLink size={16} /></a>
        </div>
        <ol className="mt-3 space-y-1 text-sm text-gray-500 list-decimal list-inside">
          <li>Share this URL via email, WhatsApp, or social media</li>
          <li>Attendees can fill out the RSVP form to register</li>
          <li>Their information will appear in the Attendees section</li>
          <li>They will receive personalised QR codes after approval (if required)</li>
        </ol>
      </Card>

      {/* QR Code */}
      <Card>
        <h2 className="font-semibold mb-3">Scan QR Code</h2>
        <p className="text-sm text-gray-500 mb-4">Scan from your smartphone camera to open the RSVP form</p>
        <QRCodeSVG value={rsvpUrl} size={180} />
        <select className="mt-3 text-sm border rounded px-2 py-1">
          <option>Black</option>
          <option>Color</option>
        </select>
      </Card>
    </div>
  );
}
```

---

### 5.7 Attendees

```typescript
// Key component structure
export default function AttendeesPage() {
  const [filters, setFilters] = useState({
    status: '', source: '', category: '', attendance: '', search: ''
  });
  const [selected, setSelected] = useState<string[]>([]);

  const { data } = useQuery(['attendees', eventId, filters], () =>
    api.get(`/events/${eventId}/attendees`, { params: filters }).then(r => r.data)
  );

  // Bulk actions
  const handleBulkEmail     = () => navigate(`email/broadcast?ids=${selected.join(',')}`);
  const handleBulkWhatsApp  = () => api.post(`/events/${eventId}/attendees/bulk-notify`, { ids: selected, channel: 'whatsapp' });
  const handleExportCsv     = () => window.open(`${API_URL}/events/${eventId}/attendees/export`);
  const handleImportCsv     = (file) => {
    const form = new FormData();
    form.append('file', file);
    api.post(`/events/${eventId}/attendees/import`, form);
  };

  return (
    <div className="p-6 space-y-4">
      {/* Stats */}
      <div className="grid grid-cols-3 gap-4">
        <StatCard label="Total RSVPs" value={data?.total} />
        <StatCard label="Arrived"     value={data?.arrived} />
        <StatCard label="Not Arrived" value={data?.notArrived} />
      </div>

      {/* Category breakdown */}
      <CategoryBreakdown categories={data?.categoryBreakdown} />

      {/* Toolbar */}
      <div className="flex flex-wrap gap-2 items-center justify-between">
        <h2 className="font-medium">All Attendees ({data?.total} RSVPs)</h2>
        <div className="flex gap-2">
          <Button size="sm" onClick={() => setShowAddModal(true)}>+ Add Attendee</Button>
          <Button size="sm" variant="outline" onClick={() => fileInputRef.current.click()}>Upload CSV</Button>
          <Button size="sm" variant="outline" onClick={handleExportCsv}>Download CSV</Button>
          <Button size="sm" variant="outline" onClick={handleBulkEmail}>Send Email</Button>
          <Button size="sm" variant="outline" onClick={handleBulkWhatsApp}>Send WhatsApp</Button>
        </div>
      </div>

      {/* Filters */}
      <FilterBar filters={filters} onChange={setFilters} categories={data?.categories} />

      {/* Table */}
      <AttendeeTable
        attendees={data?.attendees}
        selected={selected}
        onSelect={setSelected}
        onApprove={id => api.put(`/events/${eventId}/attendees/${id}`, { status: 'APPROVED' })}
        onReject={id =>  api.put(`/events/${eventId}/attendees/${id}`, { status: 'REJECTED' })}
      />
    </div>
  );
}
```

---

### 5.8 Scanner History

```typescript
export default function ScannerHistoryPage() {
  const [filters, setFilters] = useState({ search: '', category: '', checkpoint: '', scannedBy: '', method: '' });
  const { data } = useQuery(['scan-history', eventId, filters], () =>
    api.get(`/events/${eventId}/scanner-history`, { params: filters }).then(r => r.data)
  );

  return (
    <div className="p-6 space-y-4">
      {/* Stats */}
      <div className="grid grid-cols-4 gap-4">
        <StatCard label="Total Scans"    value={data?.totalScans} />
        <StatCard label="QR Scans"       value={data?.qrScans} />
        <StatCard label="Manual Scans"   value={data?.manualScans} />
        <StatCard label="Checkpoints"    value={data?.checkpointCount} />
      </div>

      {/* Filter bar + Download CSV */}
      <ScanHistoryFilters filters={filters} onChange={setFilters} />
      <Button onClick={() => window.open(`${API_URL}/events/${eventId}/scanner-history/export`)}>
        Download CSV
      </Button>

      {/* Table */}
      <ScanHistoryTable logs={data?.logs} />
    </div>
  );
}
```

---

### 5.9 Ticket Categories

```typescript
export default function CategoriesPage() {
  const { data: categories, refetch } = useQuery(['categories', eventId], () =>
    api.get(`/events/${eventId}/categories`).then(r => r.data)
  );
  const [selectedCategory, setSelectedCategory] = useState(null);

  return (
    <div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
      {/* Left: Category list */}
      <div>
        <div className="flex justify-between items-center mb-3">
          <h2 className="font-semibold">Ticket Categories</h2>
          <Button size="sm" onClick={() => api.post(`/events/${eventId}/categories`, { name: 'New Category' }).then(refetch)}>
            + Add Category
          </Button>
        </div>

        <DraggableCategoryList
          categories={categories}
          onReorder={(ids) => api.put(`/events/${eventId}/categories/reorder`, { ids })}
          onSelect={setSelectedCategory}
          onUpdate={(id, data) => api.put(`/events/${eventId}/categories/${id}`, data).then(refetch)}
          onDelete={(id) => api.delete(`/events/${eventId}/categories/${id}`).then(refetch)}
        />
      </div>

      {/* Right: Design template */}
      {selectedCategory && (
        <div>
          <h2 className="font-semibold mb-3">Design Template for {selectedCategory.name}</h2>
          <a href="https://www.canva.com" target="_blank" className="text-sm text-blue-600 hover:underline">
            Click here to view templates on Canva
          </a>
          <TemplateUpload
            categoryId={selectedCategory.id}
            currentTemplate={selectedCategory.templateUrl}
            onUpload={(file) => {
              const form = new FormData();
              form.append('image', file);
              api.put(`/events/${eventId}/categories/${selectedCategory.id}/template`, form).then(refetch);
            }}
          />
          <TemplatePreview
            backgroundUrl={selectedCategory.templateUrl}
            event={event}
          />
        </div>
      )}
    </div>
  );
}
```

---

### 5.10 Email Module

```typescript
// Email Broadcast (BroadcastPage.tsx)
export default function BroadcastPage() {
  const [body, setBody] = useState('');
  const { data: templates } = useQuery(['templates', eventId], ...);
  const { data: recipientCount } = useQuery(...); // count of approved attendees with email

  const handleSend = async () => {
    await api.post(`/events/${eventId}/email/broadcast`, { subject, body, fromName, fromEmail });
    toast.success(`Broadcast queued for ${recipientCount} recipients`);
  };

  const insertTag = (tag: string) => setBody(prev => prev + tag);

  return (
    <div className="p-6 max-w-3xl space-y-6">
      <h1 className="text-xl font-semibold">Email Broadcast</h1>
      <p className="text-sm text-gray-500">Available recipients: {recipientCount} attendees</p>

      {/* Template selector */}
      <TemplateSelector templates={templates} onSelect={(t) => { setSubject(t.subject); setBody(t.body); }} />

      {/* From Name / Email */}
      <div className="grid grid-cols-2 gap-4">
        <Field label="From Name *"><input value={fromName} onChange={e => setFromName(e.target.value)} /></Field>
        <Field label="From Email">
          <input value={fromEmail} readOnly className="bg-gray-50" />
          <p className="text-xs text-gray-400 mt-1">Configured in event settings</p>
        </Field>
      </div>

      {/* Subject */}
      <Field label="Subject *">
        <div className="flex gap-2">
          <input className="flex-1" value={subject} onChange={e => setSubject(e.target.value)} placeholder="Email subject line" />
          <Button size="sm" variant="outline" onClick={() => setSubject(s => s + ' {name}')}>Insert {`{name}`}</Button>
        </div>
      </Field>

      {/* Body */}
      <Field label="Email Body *">
        <div className="flex gap-2 mb-2">
          <Button size="sm" variant="outline" onClick={() => insertTag('{name}')}>Insert {`{name}`}</Button>
          <Button size="sm" variant="outline" onClick={() => insertTag('{QRCode}')}>Insert {`{QRCode}`}</Button>
        </div>
        <RichTextEditor value={body} onChange={setBody} />
        <p className="text-xs text-gray-400 mt-2">
          Use {`{name}`} for attendee name · {`{QRCode}`} to embed their QR code image
        </p>
      </Field>

      <div className="flex gap-3">
        <Button variant="outline" onClick={() => api.post(`/events/${eventId}/email/test`, { subject, body })}>
          Send Test Email
        </Button>
        <Button onClick={handleSend}>
          Send to {recipientCount} Recipients
        </Button>
      </div>
    </div>
  );
}
```

---

### 5.11 Photo Gallery

```typescript
export default function GalleryPage() {
  const { data: gallery } = useQuery(['gallery', eventId], () =>
    api.get(`/events/${eventId}/gallery`).then(r => r.data)
  );
  const [selectedCat, setSelectedCat] = useState(null);

  const handleUpload = async (files: File[]) => {
    const form = new FormData();
    files.forEach(f => form.append('photos', f));
    await api.post(`/events/${eventId}/gallery/categories/${selectedCat.id}/photos`, form);
  };

  return (
    <div className="p-6 space-y-4">
      <div className="flex justify-between items-center">
        <h1 className="text-xl font-semibold">Photo Gallery</h1>
        <div className="flex gap-2">
          <Button variant="outline" onClick={() => window.open(`/public/gallery/${eventId}`)}>
            View Public Gallery ↗
          </Button>
          <Button onClick={() => api.post(`/events/${eventId}/gallery/categories`, { name: 'New Album' })}>
            + New Category
          </Button>
        </div>
      </div>

      <div className="grid grid-cols-4 gap-4">
        {/* Category sidebar */}
        <div className="space-y-2">
          {gallery?.categories.map(cat => (
            <button key={cat.id} onClick={() => setSelectedCat(cat)}
              className={`w-full text-left px-3 py-2 rounded-lg text-sm ${selectedCat?.id === cat.id ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'}`}>
              {cat.name}
            </button>
          ))}
        </div>

        {/* Photo grid */}
        <div className="col-span-3">
          {selectedCat ? (
            <PhotoUploadGrid category={selectedCat} onUpload={handleUpload} />
          ) : (
            <p className="text-gray-400 text-center mt-20">Select a category to upload and view photos</p>
          )}
        </div>
      </div>
    </div>
  );
}
```

---

### 5.12 Document QR Generator

```typescript
export default function DocumentQrPage() {
  const { data: docs, refetch } = useQuery(['documents', eventId], () =>
    api.get(`/events/${eventId}/documents`).then(r => r.data)
  );

  const handleUpload = async (file: File) => {
    const form = new FormData();
    form.append('document', file);
    await api.post(`/events/${eventId}/documents`, form);
    refetch();
    toast.success('Document uploaded and QR generated!');
  };

  return (
    <div className="p-6 space-y-4">
      <h1 className="text-xl font-semibold">Document QR Generator</h1>
      <p className="text-sm text-gray-500">Upload documents and generate QR codes for easy sharing</p>

      <FileUploadZone accept=".pdf,.doc,.docx,.ppt,.pptx" onUpload={handleUpload} />

      {docs?.length === 0 ? (
        <EmptyState message="Upload your first document to generate a QR code" />
      ) : (
        <div className="space-y-3">
          {docs?.map(doc => (
            <div key={doc.id} className="border rounded-xl p-4 flex items-center gap-4">
              <FileText size={24} className="text-gray-400" />
              <div className="flex-1">
                <p className="font-medium text-sm">{doc.name}</p>
                <a href={doc.url} target="_blank" className="text-xs text-blue-600">Open document ↗</a>
              </div>
              <img src={doc.qrCodeUrl} alt="QR" className="w-20 h-20" />
              <a href={doc.qrCodeUrl} download className="text-xs text-gray-500 hover:underline">Download QR</a>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
```

---

### 5.13 Public RSVP Form

```typescript
// src/pages/public/RsvpFormPage.tsx — No auth required
export default function RsvpFormPage() {
  const { eventId } = useParams();
  const [submitted, setSubmitted] = useState(false);
  const { data: event } = useQuery(['rsvp-event', eventId], () =>
    api.get(`/rsvp/${eventId}`).then(r => r.data)
  );

  const { register, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(rsvpSchema)
  });

  const onSubmit = async (data) => {
    await api.post(`/rsvp/${eventId}`, data);
    setSubmitted(true);
  };

  if (submitted) return <RsvpSuccess />;

  return (
    <div className="min-h-screen bg-gray-50 py-8 px-4">
      <div className="max-w-lg mx-auto bg-white rounded-2xl shadow-sm border overflow-hidden">
        {event?.bannerUrl && <img src={event.bannerUrl} alt="Event banner" className="w-full aspect-square object-cover" />}

        <div className="p-6 space-y-4">
          <h1 className="text-xl font-semibold">{event?.name}</h1>
          <div className="flex items-center gap-2 text-sm text-gray-500">
            <Calendar size={14} /> {formatDate(event?.startDate)}
          </div>
          <div className="flex items-center gap-2 text-sm text-gray-500">
            <Clock size={14} /> {formatTime(event?.startDate)} – {formatTime(event?.endDate ?? event?.startDate)}
          </div>
          <a href={googleMapsUrl(event)} target="_blank"
            className="flex items-center gap-2 text-sm text-gray-500 hover:text-blue-600">
            <MapPin size={14} /> {event?.location}
          </a>

          <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 pt-2">
            <Field label="Name *" error={errors.name?.message}>
              <input {...register('name')} placeholder="Enter your name" />
            </Field>
            <Field label="Email">
              <input {...register('email')} type="email" placeholder="Enter your email (optional)" />
            </Field>
            <Field label="Mobile *" error={errors.mobile?.message}>
              <input {...register('mobile')} type="tel" placeholder="+91 XXXXX XXXXX" />
            </Field>
            <Field label="Number of Attendees *">
              <select {...register('numberOfGuests', { valueAsNumber: true })}>
                {[1,2,3,4,5].map(n => <option key={n} value={n}>{n} Attendee{n > 1 ? 's' : ''}</option>)}
              </select>
            </Field>
            <Field label="Category *">
              <select {...register('categoryId')}>
                {event?.categories?.map(cat => (
                  <option key={cat.id} value={cat.id}>{cat.name} {cat.price > 0 ? `— ₹${cat.price}` : '(Free)'}</option>
                ))}
              </select>
            </Field>

            <button type="submit" className="w-full bg-blue-600 text-white py-3 rounded-xl font-medium">
              RSVP Now
            </button>
          </form>
        </div>
      </div>
    </div>
  );
}
```

---

### 5.14 Scanner Interface

```typescript
// src/pages/scanner/ScannerDashboard.tsx
import { BarcodeScanner } from '@capacitor-community/barcode-scanner';
import { Capacitor } from '@capacitor/core';

export default function ScannerDashboard() {
  const [scanning, setScanning] = useState(false);
  const [lastScan, setLastScan] = useState(null);
  const { checkpoint } = useScannerStore();

  const startScan = async () => {
    setScanning(true);

    if (Capacitor.isNativePlatform()) {
      // Native camera QR scan
      await BarcodeScanner.checkPermission({ force: true });
      document.querySelector('body')?.classList.add('scanner-active');
      const result = await BarcodeScanner.startScan();
      document.querySelector('body')?.classList.remove('scanner-active');
      if (result.hasContent) await handleCheckin(result.content, 'QR');
    } else {
      // Web fallback: use html5-qrcode library
      startWebQrScan(async (content) => await handleCheckin(content, 'QR'));
    }

    setScanning(false);
  };

  const handleCheckin = async (shortId: string, method: string) => {
    try {
      const { data } = await api.post('/scanner/checkin', { shortId, method, checkpointId: checkpoint.id });
      setLastScan(data.attendee);
      toast.success(`✓ ${data.attendee.name} checked in`);
    } catch (err) {
      toast.error(err.response?.data?.error ?? 'Check-in failed');
    }
  };

  const handleManualSearch = async (query: string) => {
    const { data } = await api.get(`/scanner/attendee/${query}`);
    setSearchResult(data);
  };

  return (
    <div className="min-h-screen bg-gray-900 text-white p-4 space-y-4">
      <div className="flex justify-between items-center">
        <h1 className="font-semibold">Scanner — {checkpoint?.name}</h1>
        <p className="text-sm text-gray-400">{checkpoint?.event?.name}</p>
      </div>

      {/* Stats */}
      <div className="grid grid-cols-2 gap-3">
        <StatCard label="Checked In" value={stats?.arrived} dark />
        <StatCard label="Total RSVPs" value={stats?.total} dark />
      </div>

      {/* Scan button */}
      <button onClick={startScan} disabled={scanning}
        className="w-full py-4 bg-blue-600 rounded-xl font-semibold text-lg flex items-center justify-center gap-2">
        <QrCode size={24} /> {scanning ? 'Scanning…' : 'Scan QR Code'}
      </button>

      {/* Manual search */}
      <div>
        <p className="text-sm text-gray-400 mb-2">Or search manually</p>
        <input
          className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white"
          placeholder="Search by name, mobile, or ID…"
          onChange={e => handleManualSearch(e.target.value)}
        />
        {searchResult && <AttendeeCard attendee={searchResult} onCheckin={() => handleCheckin(searchResult.shortId, 'MANUAL')} />}
      </div>

      {/* Last scan */}
      {lastScan && <LastScanCard attendee={lastScan} />}
    </div>
  );
}
```

---

### 5.15 Kiosk Interface

```typescript
// src/pages/kiosk/KioskScanPage.tsx
export default function KioskScanPage() {
  const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
  const [attendee, setAttendee] = useState(null);

  // Auto-start scanning in kiosk mode
  useEffect(() => { startKioskScan(); }, []);

  const startKioskScan = async () => {
    // Continuous loop for kiosk mode
    while (true) {
      const result = await BarcodeScanner.startScan();
      if (result.hasContent) {
        try {
          const { data } = await api.post('/scanner/checkin', { shortId: result.content, method: 'QR' });
          setAttendee(data.attendee);
          setStatus('success');
          await wait(3000); // Show success for 3s
        } catch {
          setStatus('error');
          await wait(2000);
        } finally {
          setStatus('idle');
          setAttendee(null);
        }
      }
    }
  };

  return (
    <div className="min-h-screen flex flex-col items-center justify-center bg-gray-900 text-white">
      {status === 'idle' && (
        <>
          <QrCode size={80} className="text-blue-400 mb-4" />
          <h1 className="text-2xl font-semibold">Scan your QR code</h1>
          <p className="text-gray-400 text-sm mt-2">Hold your QR code in front of the camera</p>
        </>
      )}
      {status === 'success' && (
        <div className="text-center">
          <CheckCircle size={80} className="text-green-400 mx-auto mb-4" />
          <h1 className="text-2xl font-semibold">Welcome, {attendee?.name}!</h1>
          <p className="text-gray-400 mt-2">You have been checked in successfully</p>
        </div>
      )}
      {status === 'error' && (
        <div className="text-center">
          <XCircle size={80} className="text-red-400 mx-auto mb-4" />
          <h1 className="text-2xl font-semibold">Check-in Failed</h1>
          <p className="text-gray-400 mt-2">Please contact the event staff</p>
        </div>
      )}
    </div>
  );
}
```

---

### 5.16 Scanner Checkpoints (Dashboard)

```typescript
// Shown in Dashboard > Scanner Checkpoints tab
export default function CheckpointsTab() {
  const { data: checkpoints, refetch } = useQuery(['checkpoints'], () =>
    api.get('/checkpoints').then(r => r.data)
  );

  const handleCreate = async (data) => {
    await api.post('/checkpoints', data);
    refetch();
    toast.success('Checkpoint created');
  };

  return (
    <div className="space-y-4">
      <Button onClick={() => setShowModal(true)}>+ Create Checkpoint</Button>

      <table className="w-full text-sm">
        <thead>
          <tr className="text-left text-gray-500 border-b">
            <th>Checkpoint</th><th>Person</th><th>Mobile</th><th>Event</th><th>Status</th><th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {checkpoints?.map(cp => (
            <tr key={cp.id} className="border-b hover:bg-gray-50">
              <td>{cp.name}</td>
              <td>{cp.personName}</td>
              <td>{cp.mobile}</td>
              <td>{cp.event?.name}</td>
              <td>
                <Badge color={cp.isActive ? 'green' : 'gray'}>{cp.isActive ? 'Active' : 'Inactive'}</Badge>
              </td>
              <td>
                <button onClick={() => api.put(`/checkpoints/${cp.id}`, { isActive: !cp.isActive }).then(refetch)}>
                  {cp.isActive ? 'Deactivate' : 'Activate'}
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>

      <CreateCheckpointModal open={showModal} onClose={() => setShowModal(false)} onSubmit={handleCreate} />
    </div>
  );
}
```

---

## 6. Mobile (Capacitor) Setup

```bash
# After building the React app
cd apps/web
npm run build       # Creates dist/

# Add platforms
npx cap add ios
npx cap add android

# Sync web build to native projects
npx cap sync

# Open in Xcode / Android Studio
npx cap open ios
npx cap open android
```

**`capacitor.config.ts`:**

```typescript
import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'io.visity.app',
  appName: 'Visity',
  webDir: 'dist',
  server: {
    androidScheme: 'https',
    // For development with hot-reload:
    // url: 'http://YOUR_LOCAL_IP:5173',
    // cleartext: true,
  },
  plugins: {
    SplashScreen: {
      launchShowDuration: 2000,
      backgroundColor: '#1A73E8',
    },
    PushNotifications: {
      presentationOptions: ['badge', 'sound', 'alert'],
    },
  },
};

export default config;
```

**Key Capacitor plugins to install:**

```bash
npm install @capacitor-community/barcode-scanner   # QR code scanning
npm install @capacitor/push-notifications          # Push notifications
npm install @capacitor/share                       # Share RSVP links
npm install @capacitor/haptics                     # Haptic feedback on scan
npm install @capacitor/status-bar                  # Status bar theming
```

**Detecting platform in code:**

```typescript
import { Capacitor } from '@capacitor/core';

if (Capacitor.isNativePlatform()) {
  // Use native barcode scanner
} else {
  // Use web-based QR scanner (html5-qrcode)
}
```

---

## 7. Environment Variables

**Backend (`apps/api/.env`):**

```env
NODE_ENV=development
PORT=4000
DATABASE_URL=postgresql://user:password@localhost:5432/visity

JWT_SECRET=your_super_secret_jwt_key_min_32_chars
FRONTEND_URL=http://localhost:5173

# Email
SENDGRID_API_KEY=SG.xxxxxxxx
EMAIL_FROM=noreply@visity.io

# WhatsApp (choose one)
TWILIO_ACCOUNT_SID=ACxxxxxxxx
TWILIO_AUTH_TOKEN=xxxxxxxx
TWILIO_WHATSAPP_FROM=whatsapp:+14155238886

# AWS S3 / Cloudflare R2
AWS_ACCESS_KEY_ID=xxxxxxxx
AWS_SECRET_ACCESS_KEY=xxxxxxxx
AWS_REGION=ap-south-1
S3_BUCKET_NAME=visity-assets
```

**Frontend (`apps/web/.env`):**

```env
VITE_API_URL=http://localhost:4000/api
VITE_SOCKET_URL=http://localhost:4000
VITE_APP_NAME=Visity
```

---

## 8. Implementation Priority Order

Work in this sequence for the fastest path to a working product:

| Phase | Features | Milestone |
|---|---|---|
| **Phase 1** | Auth (OTP), Dashboard shell, Create Event, RSVP Form (public) | Organizer can create an event and collect RSVPs |
| **Phase 2** | Attendees (list, add, CSV import/export), QR generation, Email confirmation | Full attendee management loop |
| **Phase 3** | Scanner Login, Check-in API, Scanner UI, Kiosk UI | On-site check-in working |
| **Phase 4** | Ticket Categories + Template Design, Scanner Checkpoints | Multi-tier events and multi-gate check-in |
| **Phase 5** | Email Templates, Broadcast, Delivery Report, Email Setup | Communication features |
| **Phase 6** | Photo Gallery, Document QR Generator, Payment Settings | Utility and monetisation features |
| **Phase 7** | Capacitor build, iOS/Android testing, Push notifications | Mobile app release |

---

*Generated: April 2026 | Visity Implementation Guide v1.0*
