# FormBuilder Pro — Booking Module Skill
## Reference document for generating all booking module files

---

## 1. PROJECT CONTEXT

This skill is for building a **membership-gated multi-provider booking system** that integrates into an existing PHP 8+ web application called **FormBuilder Pro**.

### Stack
- **PHP 8.0+** — no frameworks, procedural + class-based
- **MySQL / PDO** — via the `db()` singleton in `lib.php`
- **Vanilla JS** — no React/Vue, no bundler, ES6+ in `<script>` tags
- **CSS** — plain CSS with CSS custom properties, no Tailwind/SASS
- **PHPMailer 6.x** — loaded via Composer autoload, fallback raw SMTP
- **Stripe PHP SDK** — via Composer (`stripe/stripe-php`)
- **Fonts** — Google Fonts: Syne (display), DM Sans (body), DM Mono (mono)

### Existing files (DO NOT MODIFY unless explicitly told)
```
config.php              — DB creds, SMTP, app URL, constants
lib.php                 — db(), auth, session, PDF, email helpers
api/index.php           — existing REST API (forms, submissions)
admin.php               — main admin SPA (light theme)
admin/submissions.php   — submissions inbox
admin/builder.php       — form builder
form.php                — public form renderer
index.php               — public forms list
setup.php               — setup wizard
```

### Files to CREATE for the booking module
```
booking/book.php              — public multi-step booking widget
booking/plans.php             — public membership plans page
admin/booking.php             — admin: appointments + calendar
admin/booking-plans.php       — admin: plans management
admin/booking-services.php    — admin: services management
admin/booking-providers.php   — admin: providers + hours management
api/booking.php               — all booking API routes
lib/BookingEngine.php         — slot availability engine
lib/StripeBooking.php         — Stripe checkout + webhook
```

---

## 2. DESIGN SYSTEM

### CSS Custom Properties (light theme — copy exactly)
```css
:root {
  /* Backgrounds */
  --bg: #f4f5f7;
  --surface: #ffffff;
  --card: #ffffff;
  --card-alt: #f8f9fb;
  --border: #e8eaed;
  --border-hi: #d1d5db;

  /* Single amber accent */
  --accent: #e09500;
  --accent-hi: #f5a623;
  --accent-d: #b87800;
  --accent-pale: #fff8eb;
  --accent-glow: rgba(224,149,0,0.18);
  --accent-border: rgba(224,149,0,0.3);

  /* Semantic */
  --green: #16a34a;    --green-pale: #f0fdf4;  --green-border: #bbf7d0;
  --red: #dc2626;      --red-pale: #fef2f2;    --red-border: #fecaca;
  --amber: #d97706;    --amber-pale: #fffbeb;  --amber-border: #fde68a;

  /* Text hierarchy */
  --text: #111827;
  --text-2: #4b5563;
  --text-3: #9ca3af;
  --text-inv: #ffffff;

  /* Layout */
  --sidebar-w: 240px;
  --header-h: 58px;
  --nav-h: 60px;

  /* Fonts */
  --font-display: 'Syne', sans-serif;
  --font-body: 'DM Sans', sans-serif;
  --font-mono: 'DM Mono', monospace;
}
```

### Google Fonts import (use in every HTML file)
```html
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Syne:wght@600;700;800&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
```

### Viewport meta (use in every HTML file)
```html
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/>
```

### Core UI components (reuse these exact patterns)

#### Buttons
```css
.btn { display:inline-flex; align-items:center; gap:6px; padding:9px 16px;
       border-radius:9px; font-size:13px; font-weight:600; border:none;
       cursor:pointer; transition:all .14s; white-space:nowrap;
       font-family:var(--font-body); }
.btn svg { width:14px; height:14px; stroke:currentColor; fill:none;
           stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
.btn-primary { background:linear-gradient(135deg,var(--accent-hi),var(--accent-d));
               color:#fff; box-shadow:0 1px 8px var(--accent-glow); }
.btn-primary:hover { opacity:.92; transform:translateY(-1px); }
.btn-ghost { background:transparent; color:var(--text-2);
             border:1px solid var(--border-hi); }
.btn-ghost:hover { border-color:var(--accent); color:var(--accent);
                   background:var(--accent-pale); }
.btn-danger { background:var(--red-pale); color:var(--red);
              border:1px solid var(--red-border); }
.btn-sm { padding:6px 12px; font-size:12px; border-radius:7px; }
.btn-icon { padding:7px; border-radius:8px; }
```

#### Cards
```css
.card { background:var(--surface); border:1px solid var(--border);
        border-radius:14px; overflow:hidden; margin-bottom:14px; }
.card-hdr { padding:15px 20px; border-bottom:1px solid var(--border);
            display:flex; align-items:center; justify-content:space-between; }
.card-title { font-family:var(--font-display); font-size:14px;
              font-weight:700; color:var(--text); }
.card-body { padding:20px; }
```

#### Badges
```css
.badge { display:inline-flex; align-items:center; gap:4px; padding:3px 8px;
         border-radius:20px; font-family:var(--font-mono); font-size:10px;
         font-weight:500; text-transform:uppercase; letter-spacing:.05em; }
.badge::before { content:'●'; font-size:5px; }
.badge-green { background:var(--green-pale); color:var(--green);
               border:1px solid var(--green-border); }
.badge-yellow { background:var(--amber-pale); color:var(--amber);
                border:1px solid var(--amber-border); }
.badge-red { background:var(--red-pale); color:var(--red);
             border:1px solid var(--red-border); }
.badge-gray { background:var(--bg); color:var(--text-3);
              border:1px solid var(--border); }
.badge-amber { background:var(--accent-pale); color:var(--accent);
               border:1px solid var(--accent-border); }
```

#### Code pill (for refs, slugs)
```css
.code-pill { font-family:var(--font-mono); font-size:11px;
             background:var(--accent-pale); color:var(--accent);
             border:1px solid var(--accent-border);
             padding:2px 8px; border-radius:6px; }
```

#### Toast
```css
.toast { position:fixed; bottom:24px; left:50%;
         transform:translateX(-50%) translateY(14px);
         background:var(--text); color:#fff; padding:10px 18px 10px 13px;
         border-radius:999px; font-size:13px; font-weight:500; z-index:999;
         opacity:0; transition:all .28s cubic-bezier(.34,1.56,.64,1);
         pointer-events:none; white-space:nowrap;
         box-shadow:0 4px 20px rgba(0,0,0,.18);
         display:flex; align-items:center; gap:8px; }
.toast-dot { width:7px; height:7px; border-radius:50%; flex-shrink:0; }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
.toast.ok .toast-dot { background:#4ade80; }
.toast.err .toast-dot { background:#f87171; }
.toast.warn .toast-dot { background:var(--accent-hi); }
```

#### Spinner
```css
.spinner { display:inline-block; width:20px; height:20px;
           border:2px solid var(--accent-pale);
           border-top-color:var(--accent); border-radius:50%;
           animation:spin .7s linear infinite; }
@keyframes spin { to { transform:rotate(360deg); } }
```

#### Form inputs
```css
.fg { margin-bottom:16px; }
.fg label { display:block; font-size:11px; font-weight:600; color:var(--text-2);
            text-transform:uppercase; letter-spacing:.07em; margin-bottom:6px; }
.fg input, .fg select, .fg textarea {
  width:100%; background:var(--bg); border:1.5px solid var(--border-hi);
  border-radius:9px; padding:10px 13px; font-size:13px; color:var(--text);
  outline:none; transition:border .15s, box-shadow .15s; font-family:inherit; }
.fg input:focus, .fg select:focus, .fg textarea:focus {
  border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-pale); }
.fg .hint { font-size:11px; color:var(--text-3); margin-top:4px; }
```

#### Public page wrapper (for book.php, plans.php)
```css
body { font-family:var(--font-body); background:#f4f5f7;
       min-height:100vh; -webkit-font-smoothing:antialiased;
       padding-bottom:env(safe-area-inset-bottom,0px); }
.page-wrap { max-width:720px; margin:0 auto; padding:24px 16px 60px; }
.page-card { background:#fff; border-radius:18px;
             box-shadow:0 4px 24px rgba(0,0,0,.07); overflow:hidden; }
.page-hdr { background:linear-gradient(135deg,var(--accent-hi),var(--accent-d));
            padding:28px; color:#fff; }
.page-hdr h1 { font-family:var(--font-display); font-size:clamp(18px,4vw,24px);
               font-weight:800; margin-bottom:6px; }
.page-hdr p { color:rgba(255,255,255,.85); font-size:14px; }
```

### Responsive breakpoints
```css
@media(max-width:640px) { /* mobile adjustments */ }
@media(max-width:400px) { /* xs phone adjustments */ }
```
- Always add `font-size:16px` on inputs in mobile to prevent iOS zoom
- Always include `env(safe-area-inset-bottom)` for bottom padding

---

## 3. DATABASE SCHEMA

### All booking tables use `bk_` prefix to avoid conflicts with `fb_` tables.

```sql
CREATE TABLE IF NOT EXISTS bk_services (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name         VARCHAR(120) NOT NULL,
  description  TEXT,
  duration_min SMALLINT UNSIGNED NOT NULL DEFAULT 60,
  color        VARCHAR(7)   DEFAULT '#e09500',
  is_restricted TINYINT(1)  DEFAULT 1,  -- 1=members only, 0=anyone
  is_active    TINYINT(1)   DEFAULT 1,
  created_at   DATETIME     DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_plans (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name         VARCHAR(120) NOT NULL,
  description  TEXT,
  price        DECIMAL(10,2) NOT NULL DEFAULT 0.00,
  duration_days SMALLINT UNSIGNED NOT NULL DEFAULT 30,
  color        VARCHAR(7)   DEFAULT '#e09500',
  stripe_price_id VARCHAR(100) DEFAULT NULL,
  is_active    TINYINT(1)   DEFAULT 1,
  created_at   DATETIME     DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_plan_services (
  plan_id    INT UNSIGNED NOT NULL,
  service_id INT UNSIGNED NOT NULL,
  PRIMARY KEY (plan_id, service_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_providers (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name         VARCHAR(120) NOT NULL,
  branch_name  VARCHAR(120) DEFAULT NULL,
  email        VARCHAR(200) DEFAULT NULL,
  phone        VARCHAR(30)  DEFAULT NULL,
  bio          TEXT,
  avatar_url   VARCHAR(255) DEFAULT NULL,
  timezone     VARCHAR(60)  DEFAULT 'UTC',
  is_active    TINYINT(1)   DEFAULT 1,
  created_at   DATETIME     DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_provider_services (
  id                INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  provider_id       INT UNSIGNED NOT NULL,
  service_id        INT UNSIGNED NOT NULL,
  price             DECIMAL(10,2) DEFAULT NULL,  -- NULL = use global
  capacity_per_slot TINYINT UNSIGNED DEFAULT 1,
  UNIQUE KEY uq_prov_svc (provider_id, service_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_provider_hours (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  provider_id  INT UNSIGNED NOT NULL,
  day_of_week  TINYINT UNSIGNED NOT NULL,  -- 0=Sun, 1=Mon ... 6=Sat
  start_time   TIME NOT NULL,
  end_time     TIME NOT NULL,
  is_active    TINYINT(1) DEFAULT 1,
  UNIQUE KEY uq_prov_day (provider_id, day_of_week)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_provider_breaks (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  provider_id  INT UNSIGNED NOT NULL,
  break_date   DATE NOT NULL,
  start_time   TIME NOT NULL,
  end_time     TIME NOT NULL,
  label        VARCHAR(80) DEFAULT 'Break',  -- 'Holiday', 'Lunch', etc.
  INDEX idx_prov_date (provider_id, break_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_customers (
  id           INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  email        VARCHAR(200) NOT NULL UNIQUE,
  name         VARCHAR(120) NOT NULL,
  phone        VARCHAR(30)  DEFAULT NULL,
  notes        TEXT,
  created_at   DATETIME     DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_memberships (
  id                INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  customer_id       INT UNSIGNED NOT NULL,
  plan_id           INT UNSIGNED NOT NULL,
  starts_at         DATETIME NOT NULL,
  expires_at        DATETIME NOT NULL,
  status            ENUM('active','expired','cancelled') DEFAULT 'active',
  stripe_payment_id VARCHAR(120) DEFAULT NULL,
  stripe_session_id VARCHAR(120) DEFAULT NULL,
  activated_by      ENUM('stripe','manual') DEFAULT 'manual',
  notes             TEXT,
  created_at        DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_customer (customer_id),
  INDEX idx_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_appointments (
  id             INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  ref            VARCHAR(40) NOT NULL UNIQUE,
  customer_id    INT UNSIGNED NOT NULL,
  provider_id    INT UNSIGNED NOT NULL,
  service_id     INT UNSIGNED NOT NULL,
  membership_id  INT UNSIGNED DEFAULT NULL,
  start_datetime DATETIME NOT NULL,
  end_datetime   DATETIME NOT NULL,
  status         ENUM('pending','confirmed','cancelled','no_show','completed')
                 DEFAULT 'pending',
  notes          TEXT,
  price_paid     DECIMAL(10,2) DEFAULT 0.00,
  stripe_payment_id VARCHAR(120) DEFAULT NULL,
  email_sent     TINYINT(1) DEFAULT 0,
  created_at     DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_provider_time (provider_id, start_datetime),
  INDEX idx_customer (customer_id),
  INDEX idx_ref (ref)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS bk_waitlist (
  id              INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  customer_email  VARCHAR(200) NOT NULL,
  customer_name   VARCHAR(120) NOT NULL,
  provider_id     INT UNSIGNED NOT NULL,
  service_id      INT UNSIGNED NOT NULL,
  slot_datetime   DATETIME NOT NULL,
  notified_at     DATETIME DEFAULT NULL,
  created_at      DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_slot (provider_id, slot_datetime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

### Schema init function (add to lib.php or call from api/booking.php)
```php
function init_booking_db(): void {
    $pdo = db();
    // Run all CREATE TABLE IF NOT EXISTS statements above
    // Also run ALTER TABLE IF NOT EXISTS for safe upgrades
}
```

---

## 4. PHP CONVENTIONS

### File header pattern
```php
<?php
// ─────────────────────────────────────────────────────────────
//  booking/book.php — Public multi-step booking widget
// ─────────────────────────────────────────────────────────────
require_once dirname(__DIR__) . '/lib.php';
```

### API file pattern (api/booking.php)
```php
<?php
require_once dirname(__DIR__) . '/lib.php';
cors();
session_start_safe();
init_booking_db();

$method = $_SERVER['REQUEST_METHOD'];
$uri    = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$base   = rtrim(dirname(dirname($_SERVER['SCRIPT_NAME'])), '/');
$path   = '/' . trim(substr($uri, strlen($base)), '/');
$segs   = array_values(array_filter(explode('/', $path)));
// segs: ['api', 'booking', 'resource', 'id', ...]
// Remove 'api' and 'booking' prefix
array_shift($segs); array_shift($segs);
$res = $segs[0] ?? '';
$id  = $segs[1] ?? null;
$sub = $segs[2] ?? null;

try {
    // public routes first
    // then: api_require_admin(); for admin routes
} catch (\Throwable $e) {
    error_log('Booking API: ' . $e->getMessage());
    json_out(['ok' => false, 'error' => 'Server error'], 500);
}
```

### DB query patterns
```php
// Fetch single row
$stmt = db()->prepare('SELECT * FROM bk_providers WHERE id=?');
$stmt->execute([$id]);
$row = $stmt->fetch(); // returns array or false

// Fetch all rows
$rows = db()->query('SELECT * FROM bk_services WHERE is_active=1')->fetchAll();

// Insert and get last ID
db()->prepare('INSERT INTO bk_appointments (ref,customer_id,...) VALUES (?,?,...)')
    ->execute([$ref, $customerId, ...]);
$newId = (int) db()->lastInsertId();

// Safe JSON encode for <script> blocks — ALWAYS use these flags
$safeJson = json_encode($data, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
```

### URL / path conventions
```php
// Always use APP_URL for absolute paths (survives URL rewriting)
$base = defined('APP_URL') ? APP_URL : '';
// In PHP pages: echo APP_URL . '/api/booking/slots';
// In JS embedded in PHP: const BASE = <?= json_encode(APP_URL) ?>;
```

### Auth guard for admin pages
```php
require_once dirname(__DIR__) . '/lib.php';
session_start_safe();
if (!admin_logged_in()) {
    header('Location: ' . APP_URL . '/admin.php');
    exit;
}
```

### Reference generation
```php
// Use gen_ref() from lib.php — returns e.g. 'FB-1X2Y3Z-ABC123'
// For booking refs use same function or:
function gen_booking_ref(): string {
    return 'BK-' . strtoupper(base_convert((string)time(), 10, 36))
         . '-' . strtoupper(substr(bin2hex(random_bytes(3)), 0, 6));
}
```

---

## 5. BOOKING ENGINE (lib/BookingEngine.php)

This is the core algorithm. Understand it before writing any slot-related code.

```php
class BookingEngine {

    /**
     * Get available time slots for a provider+service on a given date.
     * Returns array of slot objects.
     */
    public static function getSlots(
        int $providerId,
        int $serviceId,
        string $date  // 'Y-m-d'
    ): array {
        // 1. Get provider working hours for this day_of_week
        $dow = (int) date('w', strtotime($date)); // 0=Sun
        $hours = db()->prepare(
            'SELECT start_time, end_time FROM bk_provider_hours
             WHERE provider_id=? AND day_of_week=? AND is_active=1'
        );
        $hours->execute([$providerId, $dow]);
        $h = $hours->fetch();
        if (!$h) return []; // provider not working this day

        // 2. Get service duration + provider capacity
        $ps = db()->prepare(
            'SELECT ps.capacity_per_slot, s.duration_min
             FROM bk_provider_services ps
             JOIN bk_services s ON s.id = ps.service_id
             WHERE ps.provider_id=? AND ps.service_id=?'
        );
        $ps->execute([$providerId, $serviceId]);
        $config = $ps->fetch();
        if (!$config) return [];

        $duration = (int) $config['duration_min'];
        $capacity = (int) $config['capacity_per_slot'];

        // 3. Generate all possible slot start times
        $start = strtotime($date . ' ' . $h['start_time']);
        $end   = strtotime($date . ' ' . $h['end_time']);
        $now   = time();
        $slots = [];
        $cursor = $start;
        while ($cursor + $duration * 60 <= $end) {
            $slots[] = $cursor;
            $cursor += $duration * 60;
        }

        // 4. Get breaks for this date
        $breaks = db()->prepare(
            'SELECT start_time, end_time FROM bk_provider_breaks
             WHERE provider_id=? AND break_date=?'
        );
        $breaks->execute([$providerId, $date]);
        $breakList = $breaks->fetchAll();

        // 5. Get existing appointment counts per slot
        $appts = db()->prepare(
            'SELECT start_datetime, COUNT(*) as cnt
             FROM bk_appointments
             WHERE provider_id=? AND service_id=?
               AND DATE(start_datetime)=?
               AND status NOT IN ("cancelled","no_show")
             GROUP BY start_datetime'
        );
        $appts->execute([$providerId, $serviceId, $date]);
        $bookedMap = [];
        foreach ($appts->fetchAll() as $a) {
            $bookedMap[strtotime($a['start_datetime'])] = (int)$a['cnt'];
        }

        // 6. Build result — filter past, filter breaks
        $result = [];
        foreach ($slots as $ts) {
            if ($ts <= $now) continue; // past

            // Check break overlap
            $slotTime = date('H:i:s', $ts);
            $slotEnd  = date('H:i:s', $ts + $duration * 60);
            $inBreak = false;
            foreach ($breakList as $b) {
                if ($slotTime < $b['end_time'] && $slotEnd > $b['start_time']) {
                    $inBreak = true; break;
                }
            }
            if ($inBreak) continue;

            $booked    = $bookedMap[$ts] ?? 0;
            $available = $booked < $capacity;

            $result[] = [
                'time'      => date('H:i', $ts),
                'datetime'  => date('Y-m-d H:i:s', $ts),
                'timestamp' => $ts,
                'available' => $available,
                'booked'    => $booked,
                'capacity'  => $capacity,
                'full'      => !$available,
            ];
        }

        return $result;
    }

    /**
     * Check if a customer email has an active membership that unlocks a service.
     */
    public static function checkAccess(string $email, int $serviceId): array {
        // Get customer
        $cust = db()->prepare('SELECT * FROM bk_customers WHERE email=?');
        $cust->execute([$email]);
        $customer = $cust->fetch();
        if (!$customer) return ['allowed' => false, 'reason' => 'no_customer'];

        // Get active memberships
        $mem = db()->prepare(
            'SELECT m.*, p.name as plan_name FROM bk_memberships m
             JOIN bk_plans p ON p.id = m.plan_id
             WHERE m.customer_id=? AND m.status="active"
               AND m.expires_at > NOW()
             ORDER BY m.expires_at DESC LIMIT 1'
        );
        $mem->execute([$customer['id']]);
        $membership = $mem->fetch();
        if (!$membership) return ['allowed' => false, 'reason' => 'no_membership'];

        // Check service is in plan
        $svc = db()->prepare(
            'SELECT 1 FROM bk_plan_services
             WHERE plan_id=? AND service_id=?'
        );
        $svc->execute([$membership['plan_id'], $serviceId]);
        if (!$svc->fetch()) {
            return ['allowed' => false, 'reason' => 'service_not_in_plan'];
        }

        return [
            'allowed'    => true,
            'customer'   => $customer,
            'membership' => $membership,
        ];
    }
}
```

---

## 6. PUBLIC BOOKING FLOW (book.php)

### Multi-step flow (single page, JS-driven)
```
Step 1: Email check
  → Input email → POST /api/booking/check-membership
  → If no membership: show plans page link / buy CTA
  → If active membership: show unlocked services → go to Step 2

Step 2: Pick Service
  → Show cards for each unlocked service (name, duration, color)
  → Select one → go to Step 3

Step 3: Pick Provider
  → GET /api/booking/providers?service_id=X
  → Show provider cards (name, branch, avatar)
  → Select one → go to Step 4

Step 4: Pick Date
  → Calendar date picker (native <input type="date"> or custom)
  → Min date = today, no past dates
  → On date select → load slots

Step 5: Pick Time Slot
  → GET /api/booking/slots?service_id=X&provider_id=Y&date=Z
  → Show time grid: available = clickable, full = greyed + waitlist button
  → Select slot → go to Step 6

Step 6: Confirm Details
  → Show: name (pre-filled from customer record), phone, notes field
  → Show summary: service / provider / date+time
  → POST /api/booking/appointments → success screen

Waitlist flow (from Step 5):
  → Click "Join Waitlist" on full slot
  → Mini form: name + email
  → POST /api/booking/waitlist
```

### Step indicator HTML pattern
```html
<div class="steps">
  <div class="step done" data-n="1"><span>1</span> Email</div>
  <div class="step active" data-n="2"><span>2</span> Service</div>
  <div class="step" data-n="3"><span>3</span> Provider</div>
  <div class="step" data-n="4"><span>4</span> Date</div>
  <div class="step" data-n="5"><span>5</span> Time</div>
  <div class="step" data-n="6"><span>6</span> Confirm</div>
</div>
```

### Step indicator CSS
```css
.steps { display:flex; gap:0; margin-bottom:28px; overflow-x:auto; }
.step { display:flex; align-items:center; gap:6px; flex:1; min-width:0;
        font-size:11px; font-weight:600; color:var(--text-3);
        font-family:var(--font-mono); text-transform:uppercase;
        letter-spacing:.04em; white-space:nowrap;
        padding:10px 8px; border-bottom:2px solid var(--border);
        transition:all .2s; }
.step span { width:20px; height:20px; border-radius:50%;
             background:var(--border); color:var(--text-3);
             display:flex; align-items:center; justify-content:center;
             font-size:10px; flex-shrink:0; transition:all .2s; }
.step.active { color:var(--accent); border-bottom-color:var(--accent); }
.step.active span { background:var(--accent); color:#fff; }
.step.done { color:var(--green); border-bottom-color:var(--green); }
.step.done span { background:var(--green); color:#fff; }
```

### Slot grid CSS
```css
.slot-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(88px,1fr));
             gap:8px; margin-bottom:20px; }
.slot-btn { padding:10px 6px; border:1.5px solid var(--border);
            border-radius:10px; font-size:13px; font-weight:600;
            color:var(--text); background:var(--surface);
            cursor:pointer; transition:all .15s; text-align:center; }
.slot-btn:hover:not(.slot-full):not(.slot-selected) {
  border-color:var(--accent); background:var(--accent-pale);
  color:var(--accent); }
.slot-btn.slot-selected { background:var(--accent);
  border-color:var(--accent); color:#fff; }
.slot-btn.slot-full { background:var(--bg); color:var(--text-3);
  border-color:var(--border); cursor:default; }
.slot-full-label { font-size:9px; display:block; margin-top:2px;
                   font-family:var(--font-mono); text-transform:uppercase;
                   letter-spacing:.04em; }
.slot-waitlist-btn { font-size:10px; color:var(--amber); background:none;
                     border:none; cursor:pointer; text-decoration:underline;
                     font-family:var(--font-mono); margin-top:2px;
                     display:block; width:100%; }
```

### Service card CSS
```css
.service-card { display:flex; align-items:flex-start; gap:14px;
                padding:16px; border:1.5px solid var(--border);
                border-radius:12px; cursor:pointer; transition:all .15s;
                background:var(--surface); margin-bottom:10px; }
.service-card:hover { border-color:var(--accent); background:var(--accent-pale); }
.service-card.selected { border-color:var(--accent);
  background:var(--accent-pale); box-shadow:0 0 0 3px var(--accent-pale); }
.service-dot { width:40px; height:40px; border-radius:10px;
               flex-shrink:0; display:flex; align-items:center;
               justify-content:center; }
.service-dot svg { width:18px; height:18px; stroke:#fff; fill:none;
                   stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
.service-name { font-family:var(--font-display); font-size:14px;
                font-weight:700; color:var(--text); margin-bottom:3px; }
.service-meta { font-size:12px; color:var(--text-2); }
```

### Provider card CSS
```css
.provider-card { display:flex; align-items:center; gap:14px;
                 padding:14px 16px; border:1.5px solid var(--border);
                 border-radius:12px; cursor:pointer; transition:all .15s;
                 background:var(--surface); margin-bottom:10px; }
.provider-card:hover { border-color:var(--accent); }
.provider-card.selected { border-color:var(--accent);
  background:var(--accent-pale); }
.provider-avatar { width:44px; height:44px; border-radius:50%;
                   background:var(--accent-pale); flex-shrink:0;
                   display:flex; align-items:center; justify-content:center;
                   font-family:var(--font-display); font-size:16px;
                   font-weight:800; color:var(--accent); overflow:hidden; }
.provider-avatar img { width:100%; height:100%; object-fit:cover; }
.provider-name { font-weight:700; color:var(--text); font-size:14px;
                 margin-bottom:2px; }
.provider-branch { font-size:12px; color:var(--text-2); }
.provider-price { font-family:var(--font-mono); font-size:13px;
                  font-weight:500; color:var(--accent);
                  margin-left:auto; flex-shrink:0; }
```

### Membership plan card CSS (for plans.php)
```css
.plan-card { background:var(--surface); border:2px solid var(--border);
             border-radius:16px; padding:24px; transition:all .18s;
             position:relative; overflow:hidden; }
.plan-card:hover { border-color:var(--accent);
  box-shadow:0 8px 24px var(--accent-glow); transform:translateY(-2px); }
.plan-card.featured { border-color:var(--accent);
  box-shadow:0 4px 20px var(--accent-glow); }
.plan-badge { position:absolute; top:16px; right:16px; }
.plan-price { font-family:var(--font-display); font-size:36px;
              font-weight:800; color:var(--text); line-height:1; }
.plan-price sup { font-size:16px; vertical-align:top; margin-top:8px;
                  margin-right:2px; color:var(--text-2); }
.plan-price sub { font-size:13px; font-weight:400; color:var(--text-3); }
.plan-name { font-family:var(--font-display); font-size:18px;
             font-weight:800; color:var(--text); margin-bottom:6px; }
.plan-desc { font-size:13px; color:var(--text-2); margin-bottom:16px;
             line-height:1.6; }
.plan-services { list-style:none; margin-bottom:20px; }
.plan-services li { display:flex; align-items:center; gap:8px;
                    font-size:13px; color:var(--text-2); padding:4px 0; }
.plan-services li::before { content:'✓'; color:var(--green);
  font-weight:700; flex-shrink:0; }
.plan-duration { font-family:var(--font-mono); font-size:11px;
                 color:var(--text-3); text-transform:uppercase;
                 letter-spacing:.06em; margin-bottom:14px; }
```

---

## 7. API ROUTES (api/booking.php)

### Public endpoints
```
GET  /api/booking/plans
  Response: { ok:true, plans:[{id, name, description, price, duration_days,
             color, services:[{id,name,duration_min}]}] }

GET  /api/booking/services
  Response: { ok:true, services:[{id, name, duration_min, color, is_restricted}] }

GET  /api/booking/providers?service_id=X
  Response: { ok:true, providers:[{id, name, branch_name, bio, avatar_url,
             price, capacity_per_slot}] }

GET  /api/booking/slots?service_id=X&provider_id=Y&date=YYYY-MM-DD
  Response: { ok:true, slots:[{time, datetime, available, booked, capacity, full}] }

POST /api/booking/check-membership
  Body: { email }
  Response: {
    ok:true, found:true,
    customer: {id, email, name},
    membership: {id, plan_name, expires_at, status},
    unlocked_services: [{id, name, duration_min, color}]
  }
  OR: { ok:true, found:false } (no membership)
  OR: { ok:false, error:'...' }

POST /api/booking/appointments
  Body: { email, name, phone, service_id, provider_id, datetime, notes }
  — Validates active membership unlocks service
  — Checks slot still available
  — Creates customer if not exists
  — Inserts appointment with ref
  Response: { ok:true, ref:'BK-XXXX-XXXXXX', appointment:{...} }

POST /api/booking/waitlist
  Body: { email, name, service_id, provider_id, slot_datetime }
  Response: { ok:true }

POST /api/booking/stripe/checkout
  Body: { plan_id, email, name, success_url, cancel_url }
  Response: { ok:true, checkout_url:'https://checkout.stripe.com/...' }

POST /api/booking/stripe/webhook
  — Raw Stripe webhook body, verify signature
  — On checkout.session.completed: activate membership
```

### Admin endpoints (require api_require_admin())
```
GET    /api/booking/admin/appointments?date=&provider_id=&status=&page=
GET    /api/booking/admin/appointments/:id
PUT    /api/booking/admin/appointments/:id  { status, notes }
DELETE /api/booking/admin/appointments/:id

GET    /api/booking/admin/memberships?status=&page=
POST   /api/booking/admin/memberships       { customer_email, plan_id, starts_at, notes }
PUT    /api/booking/admin/memberships/:id   { status, expires_at }

GET/POST/PUT/DELETE  /api/booking/admin/plans
GET/POST/PUT/DELETE  /api/booking/admin/services
GET/POST/PUT/DELETE  /api/booking/admin/providers
GET/POST/PUT/DELETE  /api/booking/admin/providers/:id/hours
GET/POST/PUT/DELETE  /api/booking/admin/providers/:id/breaks

GET    /api/booking/admin/waitlist?provider_id=&slot_datetime=
DELETE /api/booking/admin/waitlist/:id

GET    /api/booking/admin/customers?search=
GET    /api/booking/admin/stats
  Response: { ok:true, total_appointments, today, this_week,
              active_memberships, expired_memberships, top_services }
```

---

## 8. STRIPE INTEGRATION (lib/StripeBooking.php)

```php
class StripeBooking {

    public static function createCheckout(
        int $planId,
        string $email,
        string $name,
        string $successUrl,
        string $cancelUrl
    ): string {
        // Load Stripe
        \Stripe\Stripe::setApiKey(defined('STRIPE_SECRET_KEY') ? STRIPE_SECRET_KEY : '');

        // Get plan
        $plan = db()->prepare('SELECT * FROM bk_plans WHERE id=? AND is_active=1');
        $plan->execute([$planId]);
        $p = $plan->fetch();
        if (!$p) throw new \Exception('Plan not found');

        // Create or get customer in Stripe (optional — skip if no Stripe customer needed)
        // Create checkout session
        $session = \Stripe\Checkout\Session::create([
            'customer_email' => $email,
            'payment_method_types' => ['card'],
            'line_items' => [[
                'price_data' => [
                    'currency' => defined('STRIPE_CURRENCY') ? STRIPE_CURRENCY : 'usd',
                    'unit_amount' => (int)($p['price'] * 100),
                    'product_data' => [
                        'name' => $p['name'],
                        'description' => $p['description'] ?? '',
                    ],
                ],
                'quantity' => 1,
            ]],
            'mode' => 'payment',
            'success_url' => $successUrl . '?session_id={CHECKOUT_SESSION_ID}',
            'cancel_url' => $cancelUrl,
            'metadata' => [
                'plan_id' => $planId,
                'customer_email' => $email,
                'customer_name' => $name,
            ],
        ]);

        return $session->url;
    }

    public static function handleWebhook(string $payload, string $sigHeader): void {
        $secret = defined('STRIPE_WEBHOOK_SECRET') ? STRIPE_WEBHOOK_SECRET : '';
        \Stripe\Stripe::setApiKey(defined('STRIPE_SECRET_KEY') ? STRIPE_SECRET_KEY : '');

        $event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);

        if ($event->type === 'checkout.session.completed') {
            $session = $event->data->object;
            $meta = $session->metadata;
            $planId = (int) $meta->plan_id;
            $email  = $meta->customer_email;
            $name   = $meta->customer_name;

            // Get or create customer
            $custStmt = db()->prepare('SELECT id FROM bk_customers WHERE email=?');
            $custStmt->execute([$email]);
            $cust = $custStmt->fetch();
            if (!$cust) {
                db()->prepare('INSERT INTO bk_customers (email,name) VALUES (?,?)')
                    ->execute([$email, $name]);
                $customerId = (int) db()->lastInsertId();
            } else {
                $customerId = (int) $cust['id'];
            }

            // Get plan duration
            $planStmt = db()->prepare('SELECT duration_days FROM bk_plans WHERE id=?');
            $planStmt->execute([$planId]);
            $plan = $planStmt->fetch();

            $startsAt  = date('Y-m-d H:i:s');
            $expiresAt = date('Y-m-d H:i:s', strtotime('+' . $plan['duration_days'] . ' days'));

            db()->prepare(
                'INSERT INTO bk_memberships
                 (customer_id,plan_id,starts_at,expires_at,status,stripe_payment_id,
                  stripe_session_id,activated_by)
                 VALUES (?,?,?,?,"active",?,?,"stripe")'
            )->execute([
                $customerId, $planId, $startsAt, $expiresAt,
                $session->payment_intent, $session->id
            ]);

            // TODO: send confirmation email via send_mail()
        }
    }
}
```

### Config additions needed in config.php
```php
// ── Stripe ──────────────────────────────────────────────────
define('STRIPE_SECRET_KEY',     '');  // sk_live_... or sk_test_...
define('STRIPE_PUBLISHABLE_KEY','');  // pk_live_... or pk_test_...
define('STRIPE_WEBHOOK_SECRET', '');  // whsec_...
define('STRIPE_CURRENCY',       'usd');
```

---

## 9. EMAIL NOTIFICATIONS

Use existing `send_mail()` from lib.php. Send these emails:

| Trigger | To | Subject |
|---|---|---|
| Appointment created | Customer | "Booking Confirmed — {service} with {provider}" |
| Appointment created | Provider | "New Booking — {customer} for {service}" |
| Membership activated (Stripe) | Customer | "Membership Active — {plan name}" |
| Membership activated (manual) | Customer | "Your Membership Has Been Activated" |
| Waitlist notified | Customer | "A Slot Has Opened — Book Now" |
| Appointment reminder (optional) | Customer | "Reminder: Your appointment tomorrow" |

Always include:
- Booking reference (BK-XXX-XXXXXX)
- Date, time, service, provider name
- Link back to booking page to cancel/reschedule

---

## 10. ADMIN PAGE PATTERNS

Admin pages follow the same light theme and use the same CSS from admin.php.

### Admin page shell (for standalone pages like admin/booking.php)
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"/>
<title>{Page Title} — FormBuilder Admin</title>
<!-- [Google Fonts] -->
<style>
/* [Full CSS from design system section above] */
/* Plus page-specific styles */
</style>
</head>
<body>
<div class="topbar">
  <a href="../admin.php" class="topbar-logo">
    <div class="topbar-logo-icon"><!-- FB icon --></div>
    FormBuilder
  </a>
  <div class="topbar-divider"></div>
  <span class="topbar-section">Booking</span>
  <span class="topbar-title">{Page Name}</span>
  <div class="topbar-actions"><!-- page actions --></div>
  <a href="../admin.php" class="btn btn-ghost btn-sm">← Dashboard</a>
</div>

<div class="admin-shell">
  <!-- left nav tabs (Appointments / Plans / Services / Providers) -->
  <nav class="admin-tabs">...</nav>
  <!-- main content -->
  <div class="admin-content" id="content">...</div>
</div>
```

### Topbar CSS for admin booking pages
```css
.topbar { position:fixed; top:0; left:0; right:0; height:58px;
          background:#fff; border-bottom:1px solid var(--border);
          display:flex; align-items:center; padding:0 20px;
          gap:12px; z-index:100; box-shadow:0 1px 3px rgba(0,0,0,.04); }
.topbar-logo { display:flex; align-items:center; gap:8px;
               font-family:var(--font-display); font-weight:800;
               font-size:14px; color:var(--text); text-decoration:none; }
.topbar-logo-icon { width:26px; height:26px;
                    background:linear-gradient(135deg,var(--accent-hi),var(--accent-d));
                    border-radius:7px; display:flex; align-items:center;
                    justify-content:center; }
.topbar-logo-icon svg { width:12px; height:12px; stroke:#fff; fill:none;
                        stroke-width:2.5; stroke-linecap:round; stroke-linejoin:round; }
.topbar-divider { width:1px; height:20px; background:var(--border); }
.topbar-section { font-family:var(--font-mono); font-size:10px;
                  color:var(--text-3); text-transform:uppercase; letter-spacing:.1em; }
.topbar-title { font-family:var(--font-display); font-size:15px;
                font-weight:700; color:var(--text); flex:1; }
```

---

## 11. CRITICAL RULES — ALWAYS FOLLOW

1. **json_encode in `<script>` blocks** — always use `JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP`
2. **Script paths** — always use `APP_URL` constant, never relative paths
3. **Email queue** — use `send_mail()` from lib.php, never call PHPMailer directly inline
4. **iconv before PDF streams** — `iconv('UTF-8', 'windows-1252//TRANSLIT//IGNORE', $text)` for PDF text
5. **bk_ prefix** — all booking tables use `bk_` not `fb_`
6. **No inline SQL** — always use prepared statements with `?` placeholders
7. **Mobile inputs** — `font-size:16px` on mobile to prevent iOS zoom
8. **All buttons** — amber text is `#fff` not `#000` (gradient is dark enough)
9. **SortableJS** — if needed for drag-drop, use `animation:150` and read order from DOM
10. **Stripe webhook** — MUST read raw body with `file_get_contents('php://input')` BEFORE any JSON parsing
11. **Membership check** — ALWAYS verify `expires_at > NOW()` AND `status = 'active'` together
12. **Slot conflict check** — when creating appointment, wrap in transaction and re-check availability to prevent race conditions
13. **CSRF** — admin POST actions use `credentials:'same-origin'` with existing session auth
14. **Table prefix consistency** — `bk_` for booking, `fb_` for forms — never mix

---

## 12. FILE CHECKLIST

When generating files for this module, verify each file:
- [ ] PHP header with file description comment
- [ ] `require_once` path uses `dirname(__DIR__)` correctly
- [ ] All CSS variables match design system exactly
- [ ] Google Fonts imported
- [ ] `viewport-fit=cover` in meta viewport
- [ ] `env(safe-area-inset-bottom)` on bottom padding
- [ ] Mobile `font-size:16px` on inputs
- [ ] JS `const BASE = <?= json_encode(APP_URL, JSON_HEX_TAG|JSON_HEX_QUOT) ?>;`
- [ ] All `json_encode` in script blocks use HEX flags
- [ ] Toast component included
- [ ] Spinner animation included
- [ ] `@media(max-width:640px)` block present
- [ ] No hardcoded colors outside CSS variables
- [ ] No old cyan `#02aced` references
- [ ] `bk_` prefix on all new table names
