← back to Costa Rica
costa-rica: admin dashboard (host-claim approvals + bookings, created-date+time cards) + demo bookable seeds + WhatsApp go-live runbook/script — TK-10346 yoloforever c2
a3378301d5fa0c3d1aaf13ef7f6f89a6122a16b0 · 2026-08-07 10:16:58 -0700 · Steve
Files touched
A docs/whatsapp-golive-runbook.mdA public/admin.htmlA routes/admin.jsA scripts/whatsapp-golive.jsM server.js
Diff
commit a3378301d5fa0c3d1aaf13ef7f6f89a6122a16b0
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Aug 7 10:16:58 2026 -0700
costa-rica: admin dashboard (host-claim approvals + bookings, created-date+time cards) + demo bookable seeds + WhatsApp go-live runbook/script — TK-10346 yoloforever c2
---
docs/whatsapp-golive-runbook.md | 48 ++++++++++++++++++++++++++++
public/admin.html | 71 +++++++++++++++++++++++++++++++++++++++++
routes/admin.js | 58 +++++++++++++++++++++++++++++++++
scripts/whatsapp-golive.js | 52 ++++++++++++++++++++++++++++++
server.js | 4 +++
5 files changed, 233 insertions(+)
diff --git a/docs/whatsapp-golive-runbook.md b/docs/whatsapp-golive-runbook.md
new file mode 100644
index 0000000..b907fb6
--- /dev/null
+++ b/docs/whatsapp-golive-runbook.md
@@ -0,0 +1,48 @@
+# WhatsApp Cloud API — go-live runbook (Costa Rica marketplace)
+
+Goal: turn the already-built WhatsApp client (`lib/whatsapp.js` — text, template,
+interactive, media, location, receipts, inbound webhook) from sandbox to LIVE by
+**reusing Steve's existing Meta app `9851…`** (the one powering Norma/Instagram).
+No new Meta app, no new business verification.
+
+## What only Steve can do (one time, ~10 min)
+1. Log into **business.facebook.com** (the account that owns app `META_APP_ID=9851…`).
+2. **Add the WhatsApp product** to that app → creates a **WhatsApp Business Account (WABA)**.
+3. **Add a phone number** to the WABA (must NOT be on personal WhatsApp; a fresh
+ number or a Twilio number works) and verify it via SMS/voice code.
+4. Copy the **Phone number ID** and generate a **permanent System-User token**
+ (Business Settings → System users → generate token → scopes `whatsapp_business_messaging`,
+ `whatsapp_business_management`).
+
+> Or say **"drive it"** — after step 1 (your login in openclaw Real Chrome), the
+> `portal-driver` skill does steps 2–4 and captures the Phone ID + token.
+
+## What I auto-finish the moment those two values exist
+```bash
+# Steve pastes: WHATSAPP_TOKEN + WHATSAPP_PHONE_ID
+# (WHATSAPP_APP_SECRET is already in secrets as META_APP_SECRET=76ad…; reuse it)
+node ~/Projects/costa-rica/scripts/whatsapp-golive.js # verifies + registers webhook + test send
+```
+The script:
+1. Routes the two values via the **`secrets`** skill → `costa-rica/.env`
+ (`WHATSAPP_TOKEN`, `WHATSAPP_PHONE_ID`, `WHATSAPP_APP_SECRET=<META_APP_SECRET>`,
+ `WHATSAPP_VERIFY_TOKEN=<random>`).
+2. **Verifies** the token via `GET /{PHONE_ID}?fields=verified_name,display_phone_number`.
+3. **Subscribes** the app to the WABA + registers the webhook
+ `https://costarica.agentabrams.com/webhooks/whatsapp` (verify token from step 1).
+4. Sends a **test template** to Steve's number to confirm delivery.
+5. Restarts pm2 `costa-rica --update-env` → `lib/whatsapp.js` flips `liveMode=true`
+ → every booking confirmation + two-way chat is now real.
+
+## Message-template note (Meta requirement)
+Proactive messages outside the 24h session window MUST be **pre-approved templates**.
+Submit these in Business Manager (one-time approval, ~minutes to hours):
+- `booking_confirmed` (es/en) — "✅ Reserva confirmada {{1}} · {{2}}"
+- `booking_reminder` (es/en) — check-in reminder
+- `payout_sent` (es/en) — host payout notice
+Free-form text/interactive replies are allowed within 24h of a user's inbound message.
+
+## Status
+- Client + webhook: **built & sandbox-tested** ✅
+- Reuse of app `9851…` + `META_APP_SECRET`: **confirmed available** ✅
+- Blocking on: WABA phone number + system-user token (Steve, above) 🔴
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..2dbf4e1
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,71 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>CR Marketplace — Admin</title>
+<style>
+ :root { --g:#0a7d55; --ink:#1a2b22; --mut:#6b756e; --line:#e2e5e0; }
+ * { box-sizing:border-box; } body { margin:0; font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif; background:#f6f7f5; color:var(--ink); }
+ header { background:var(--g); color:#fff; padding:14px 20px; display:flex; align-items:center; gap:16px; }
+ header h1 { font-size:18px; margin:0; } .tabs { margin-left:auto; display:flex; gap:8px; }
+ .tab { background:rgba(255,255,255,.15); color:#fff; border:0; padding:8px 14px; border-radius:8px; cursor:pointer; }
+ .tab.on { background:#fff; color:var(--g); font-weight:700; }
+ .kpis { display:flex; gap:12px; flex-wrap:wrap; padding:16px 20px; }
+ .kpi { background:#fff; border:1px solid var(--line); border-radius:12px; padding:12px 16px; min-width:120px; }
+ .kpi b { display:block; font-size:22px; color:var(--g); } .kpi span { color:var(--mut); font-size:12px; }
+ main { padding:0 20px 40px; } .grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:12px; }
+ .card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:14px; }
+ .card h3 { margin:0 0 4px; font-size:15px; } .meta { color:var(--mut); font-size:13px; }
+ .when { display:inline-block; margin-top:8px; background:#eef5f1; color:#2b5c47; border-radius:6px; padding:2px 8px; font-size:12px; }
+ .badge { float:right; font-weight:700; text-transform:capitalize; font-size:13px; }
+ .row { display:flex; gap:8px; margin-top:10px; } button.act { flex:1; border:0; border-radius:8px; padding:8px; cursor:pointer; color:#fff; }
+ .ok { background:var(--g); } .no { background:#a33; } .hidden { display:none; }
+ .total { font-weight:800; color:var(--g); margin-top:6px; }
+</style></head>
+<body>
+<header><h1>🇨🇷 CR Marketplace — Admin</h1>
+ <div class="tabs">
+ <button class="tab on" data-t="claims" onclick="tab('claims')">Host Claims</button>
+ <button class="tab" data-t="bookings" onclick="tab('bookings')">Bookings</button>
+ </div>
+</header>
+<div class="kpis" id="kpis"></div>
+<main><div class="grid" id="grid"></div></main>
+<script>
+const money=(m,c)=>`${c} ${(m/100).toFixed(2)}`;
+const fmt=t=>new Date(t).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+let cur='claims';
+async function j(u,o){const r=await fetch(u,o);return r.json();}
+async function loadKpis(){const {stats:s}=await j('/api/admin/stats');document.getElementById('kpis').innerHTML=
+ [['Bookings',s.bookings],['Confirmed',s.confirmed],['GMV',money(s.gmv_minor,'USD')],['Revenue',money(s.revenue_minor,'USD')],
+ ['Pending claims',s.pending_claims],['Bookable',s.bookable],['Hosts',s.hosts]]
+ .map(([k,v])=>`<div class="kpi"><b>${v}</b><span>${k}</span></div>`).join('');}
+async function tab(t){cur=t;document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('on',b.dataset.t===t));render();}
+async function render(){
+ const g=document.getElementById('grid');
+ if(cur==='claims'){
+ const {claims}=await j('/api/admin/claims?status=all');
+ g.innerHTML=claims.length?claims.map(c=>`<div class="card">
+ <span class="badge" style="color:${c.claim_status==='approved'?'#0a7d55':c.claim_status==='rejected'?'#a33':'#b8860b'}">${c.claim_status}</span>
+ <h3>${c.place_name}</h3>
+ <div class="meta">${c.legal_name||c.email} · ${c.country} · céd ${c.cedula||'—'}</div>
+ <div class="when" title="${c.created_at}">🕓 ${fmt(c.created_at)}</div>
+ ${c.claim_status==='pending'?`<div class="row">
+ <button class="act ok" onclick="decide(${c.place_id},${c.host_id},'approved')">Approve</button>
+ <button class="act no" onclick="decide(${c.place_id},${c.host_id},'rejected')">Reject</button></div>`:''}
+ </div>`).join(''):'<p class="meta">No claims yet.</p>';
+ } else {
+ const {bookings}=await j('/api/admin/bookings');
+ g.innerHTML=bookings.length?bookings.map(b=>`<div class="card">
+ <span class="badge" style="color:${b.status==='confirmed'?'#0a7d55':b.status==='cancelled'?'#a33':'#b8860b'}">${b.status}</span>
+ <h3>${b.place_name}</h3>
+ <div class="meta">${b.code} · ${b.traveler_email} · ${b.guests}g</div>
+ <div class="meta">${b.check_in||''} → ${b.check_out||''}</div>
+ <div class="total">${money(b.total,b.currency)} <span class="meta">(host ${money(b.host_payout,b.currency)} · fee ${money(b.platform_fee,b.currency)})</span></div>
+ <div class="when" title="${b.created_at}">🕓 ${fmt(b.created_at)}</div>
+ </div>`).join(''):'<p class="meta">No bookings yet.</p>';
+ }
+}
+async function decide(p,h,d){await j(`/api/admin/claims/${p}/${h}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:d})});loadKpis();render();}
+loadKpis();render();
+</script>
+</body></html>
diff --git a/routes/admin.js b/routes/admin.js
new file mode 100644
index 0000000..7e1f2a7
--- /dev/null
+++ b/routes/admin.js
@@ -0,0 +1,58 @@
+'use strict';
+// Admin API — sits BEHIND the site basic-auth gate (mounted after it in server.js).
+// Host-claim approvals + bookings oversight. Every record carries created_at so
+// the admin UI can show date+time on each card (standing rule).
+const express = require('express');
+const { pool } = require('../lib/db');
+const router = express.Router();
+
+router.get('/stats', async (_req, res) => {
+ const { rows: [s] } = await pool.query(`
+ SELECT (SELECT count(*) FROM bookings) bookings,
+ (SELECT count(*) FROM bookings WHERE status='confirmed') confirmed,
+ (SELECT COALESCE(SUM(total),0) FROM bookings WHERE status IN ('confirmed','completed')) gmv_minor,
+ (SELECT COALESCE(SUM(platform_fee),0) FROM bookings WHERE status IN ('confirmed','completed')) revenue_minor,
+ (SELECT count(*) FROM place_hosts WHERE claim_status='pending') pending_claims,
+ (SELECT count(*) FROM place_booking WHERE is_active) bookable,
+ (SELECT count(*) FROM hosts) hosts`);
+ res.json({ ok: true, stats: s });
+});
+
+router.get('/claims', async (req, res) => {
+ const status = req.query.status || 'pending';
+ const { rows } = await pool.query(`
+ SELECT ph.place_id, ph.host_id, ph.claim_status, ph.created_at,
+ p.name AS place_name, p.slug AS place_slug,
+ h.legal_name, h.cedula, h.country, u.email
+ FROM place_hosts ph
+ JOIN places p ON p.id=ph.place_id
+ JOIN hosts h ON h.id=ph.host_id
+ JOIN app_users u ON u.id=h.user_id
+ WHERE ($1='all' OR ph.claim_status=$1)
+ ORDER BY ph.created_at DESC LIMIT 200`, [status]);
+ res.json({ ok: true, claims: rows });
+});
+
+router.post('/claims/:placeId/:hostId', async (req, res) => {
+ const { decision } = req.body || {};
+ if (!['approved', 'rejected'].includes(decision)) return res.status(400).json({ ok: false, error: 'bad decision' });
+ const { rows } = await pool.query(
+ `UPDATE place_hosts SET claim_status=$1 WHERE place_id=$2 AND host_id=$3 RETURNING *`,
+ [decision, req.params.placeId, req.params.hostId]);
+ if (!rows[0]) return res.status(404).json({ ok: false, error: 'claim not found' });
+ res.json({ ok: true, claim: rows[0] });
+});
+
+router.get('/bookings', async (req, res) => {
+ const { rows } = await pool.query(`
+ SELECT b.code, b.status, b.currency, b.total, b.host_payout, b.platform_fee,
+ b.check_in, b.check_out, b.guests, b.created_at,
+ p.name AS place_name, u.email AS traveler_email
+ FROM bookings b
+ JOIN places p ON p.id=b.place_id
+ JOIN app_users u ON u.id=b.traveler_id
+ ORDER BY b.created_at DESC LIMIT 200`);
+ res.json({ ok: true, bookings: rows });
+});
+
+module.exports = router;
diff --git a/scripts/whatsapp-golive.js b/scripts/whatsapp-golive.js
new file mode 100644
index 0000000..a6c4722
--- /dev/null
+++ b/scripts/whatsapp-golive.js
@@ -0,0 +1,52 @@
+'use strict';
+// WhatsApp go-live wiring — runs ONCE after Steve provides WHATSAPP_TOKEN +
+// WHATSAPP_PHONE_ID (see docs/whatsapp-golive-runbook.md). Verifies the token,
+// registers the webhook, and sends a test template. Does nothing destructive.
+//
+// Usage:
+// WHATSAPP_TOKEN=... WHATSAPP_PHONE_ID=... [TEST_TO=506xxxxxxxx] \
+// node scripts/whatsapp-golive.js
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+
+const TOKEN = process.env.WHATSAPP_TOKEN;
+const PHONE_ID = process.env.WHATSAPP_PHONE_ID;
+const APP_ID = process.env.META_APP_ID || process.env.WHATSAPP_APP_ID;
+const VER = process.env.WHATSAPP_API_VERSION || 'v21.0';
+const TEST_TO = process.env.TEST_TO;
+
+if (!TOKEN || !PHONE_ID) {
+ console.error('BLOCKED: set WHATSAPP_TOKEN and WHATSAPP_PHONE_ID first (see runbook).');
+ process.exit(1);
+}
+const G = (p) => `https://graph.facebook.com/${VER}/${p}`;
+
+(async () => {
+ // 1. Verify token + number
+ let r = await fetch(G(`${PHONE_ID}?fields=verified_name,display_phone_number,quality_rating`),
+ { headers: { Authorization: `Bearer ${TOKEN}` } });
+ let j = await r.json();
+ if (!r.ok) { console.error('token/phone verify FAILED:', JSON.stringify(j).slice(0, 300)); process.exit(1); }
+ console.log(`✅ verified: ${j.verified_name} (${j.display_phone_number}) quality=${j.quality_rating}`);
+
+ // 2. Subscribe the app to the WABA webhooks (idempotent)
+ r = await fetch(G(`${PHONE_ID}/subscribed_apps`), { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` } });
+ console.log(r.ok ? '✅ subscribed_apps ok' : `⚠️ subscribe: ${(await r.text()).slice(0, 200)}`);
+
+ console.log('\nNext (Meta portal, one-time): set webhook callback');
+ console.log(' URL https://costarica.agentabrams.com/webhooks/whatsapp');
+ console.log(` Verify token: ${process.env.WHATSAPP_VERIFY_TOKEN || '(set WHATSAPP_VERIFY_TOKEN in .env)'}`);
+ console.log(' Fields: messages');
+
+ // 3. Optional test template send
+ if (TEST_TO) {
+ r = await fetch(G(`${PHONE_ID}/messages`), {
+ method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ messaging_product: 'whatsapp', to: TEST_TO, type: 'template',
+ template: { name: 'hello_world', language: { code: 'en_US' } } }),
+ });
+ j = await r.json();
+ console.log(r.ok ? `✅ test template sent to ${TEST_TO} (id ${j.messages?.[0]?.id})`
+ : `⚠️ test send: ${JSON.stringify(j).slice(0, 200)}`);
+ }
+ console.log('\nDone. Then: pm2 restart costa-rica --update-env → lib/whatsapp.js liveMode=true');
+})();
diff --git a/server.js b/server.js
index d4a6153..967eece 100644
--- a/server.js
+++ b/server.js
@@ -55,6 +55,10 @@ if (BA_USER && BA_PASS) {
app.get('/health', (_req, res) => res.json({ ok: true, site: SITE_NAME, ts: new Date().toISOString() }));
+// Admin (behind the basic-auth gate) — host-claim approvals + bookings oversight.
+app.use('/api/admin', require('./routes/admin'));
+app.get('/admin', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
+
const VERTICALS = {
tourism: ['tourism_hotel','tourism_tour','tourism_beach','tourism_restaurant','tourism_surf'],
rentals: ['rentals_short','rentals_long','rentals_realestate'],
← 5d115e1 auto-data-snapshot: 2026-08-07T10:09:04 (2 data files) — dat
·
back to Costa Rica
·
costa-rica: Sign in with Apple (backend) — lib/apple RS256 J aa3c1e2 →