CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Claude/review remaining tasks forrm #3718

Merged⚡ AI-generatedXLccantynz wants to mergeclaude/review-remaining-tasks-FORRMmainopened Mar 25, 2026
56 changed files+666−6663
ModifiedCLAUDE.md+14−8View fileUnifiedSplit
919118. **Consistent API response format:** `{ ok: true/false, data: ..., error: "..." }`
929219. **All dates stored in ISO 8601 UTC.** Display in NZ timezone on frontend only.
9393
94### Build Verification (MANDATORY — No Exceptions)
9520. **Run `cd frontend && yarn build` after EVERY set of changes.** If it doesn't build, it doesn't get committed.
9621. **After build passes, run a full import/route crawl** — verify every page component loads, every import resolves, no undefined references.
9722. **No broken features on the live site.** If a feature doesn't work end-to-end, remove it or fix it. Never ship half-working code.
9823. **Test what you changed.** Don't just read the code — verify it compiles, verify the logic executes, verify the API endpoints respond.
99
94100### Autonomous Operation (Rules 2, 3, 4, 10)
9520. **If you find a bug while working on something else, fix it.** Don't leave it for later.
9621. **If a dependency is outdated and has known vulnerabilities, flag it.**
9722. **If an engineering gap exists (missing validation, missing error handling, missing indexes), fix it on the spot.**
9823. **Never introduce a new technology or provider without explicit owner approval.** The stack is locked.
10124. **If you find a bug while working on something else, fix it.** Don't leave it for later.
10225. **If a dependency is outdated and has known vulnerabilities, flag it.**
10326. **If an engineering gap exists (missing validation, missing error handling, missing indexes), fix it on the spot.**
10427. **Never introduce a new technology or provider without explicit owner approval.** The stack is locked.
99105
100106## Important Rules for AI Sessions
101107
122128- `api/bookings.js` - Booking CRUD endpoints
123129- `api/admin/` - Admin dashboard API endpoints
124130
125## Legacy Files (RETIRED — Do Not Use)
131## Legacy Files (DELETED — March 2026)
126132
127- `backend/` - Former FastAPI backend (retired March 2026, kept for reference only)
128- `Dockerfile` - Former Docker config for Render
129- `render.yaml` - Former Render deployment config
133- `backend/` - Former FastAPI backend — **DELETED** (code lives in `api/_shared/`)
134- `Dockerfile` - Former Docker config — **DELETED**
135- `render.yaml` - Former Render config — **DELETED**
DeletedDockerfile+0−30View fileUnifiedSplit
1# Hibiscus-to-airport (Render Docker)
2# Runs FastAPI app defined in backend/server.py as: app = FastAPI(...)
3FROM python:3.11-slim
4
5ENV PYTHONDONTWRITEBYTECODE=1
6ENV PYTHONUNBUFFERED=1
7
8WORKDIR /app
9
10# System deps (keep minimal)
11RUN apt-get update && apt-get install -y --no-install-recommends \
12 curl \
13 && rm -rf /var/lib/apt/lists/*
14
15# Install python deps first for layer caching
16COPY backend/requirements.txt /app/backend/requirements.txt
17RUN pip install --no-cache-dir -r /app/backend/requirements.txt
18
19# Copy application code
20COPY backend /app/backend
21
22# Render provides $PORT. Default to 10000 for local runs.
23ENV PORT=10000
24
25# Optional: container-level healthcheck (does not block Render, but helps locally/other platforms)
26HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
27 CMD curl -fsS "http://127.0.0.1:${PORT}/debug/stamp" || exit 1
28
29# IMPORTANT: run the REAL module path (no Root Directory ambiguity)
30CMD ["sh","-lc","python -c \"import backend.server as s; assert hasattr(s,'app'); print('IMPORT_OK')\" && uvicorn backend.server:app --host 0.0.0.0 --port ${PORT}"]
Modifiedapi/_shared/agent_routes.py+11−1View fileUnifiedSplit
77import time, uuid, os
88
99router = APIRouter()
10_JOBS = [] # newest first
10_JOBS = [] # Bounded queue, max 50 items, auto-cleanup after 1 hour
11_JOBS_MAX = 50
12_JOBS_TTL_SECONDS = 3600 # 1 hour
13
14def _cleanup_jobs():
15 """Remove expired jobs."""
16 global _JOBS
17 now = int(time.time())
18 _JOBS = [j for j in _JOBS if now - j.get("ts", 0) < _JOBS_TTL_SECONDS][:_JOBS_MAX]
1119
1220class CockpitRun(BaseModel):
1321 action: str
102110
103111@router.get("/api/cockpit/state")
104112def state():
113 _cleanup_jobs()
105114 return JSONResponse({"ok": True, "ts": _now(), "jobs": _JOBS[:15], "jobsCount": len(_JOBS),
106115 "agents": {"ping": "/api/agents/ping", "repair": "/api/agents/repair", "patchBuilder": "/api/agents/patch-builder"}})
107116
108117@router.post("/api/cockpit/run")
109118async def run(body: CockpitRun, request: Request):
119 _cleanup_jobs()
110120 job = {"id": str(uuid.uuid4()), "ts": _now(), "kind": body.action, "payload": {"prompt": body.prompt or "", "meta": body.meta or {}}, "status":"queued"}
111121 _JOBS.insert(0, job); del _JOBS[50:]
112122 return JSONResponse({"ok": True, "job": job})
Modifiedapi/_shared/booking_routes.py+27−22View fileUnifiedSplit
1212import io
1313from dotenv import load_dotenv
1414from pathlib import Path
15from auth import get_current_user, verify_password, create_access_token, get_password_hash
15from auth import get_current_user, verify_password, create_access_token, get_password_hash, decode_token
1616
1717# Load environment variables
1818ROOT_DIR = Path(__file__).parent
2424 send_admin_notification,
2525 send_admin_sms_notification,
2626 send_customer_sms,
27 send_email,
28 send_sms,
2729 generate_booking_reference,
2830 send_cancellation_email,
2931 send_cancellation_sms,
4547from db import get_pool
4648import json
4749
50# Camel-to-snake column mapping for database operations
51CAMEL_TO_SNAKE = {
52 "pickupAddress": "pickup_address",
53 "dropoffAddress": "dropoff_address",
54 "totalPrice": "total_price",
55 "updatedAt": "updated_at",
56 "createdAt": "created_at",
57 "serviceType": "service_type",
58 "departureFlightNumber": "departure_flight_number",
59 "departureTime": "departure_time",
60 "arrivalFlightNumber": "arrival_flight_number",
61 "arrivalTime": "arrival_time",
62 "vipPickup": "vip_pickup",
63 "oversizedLuggage": "oversized_luggage",
64 "returnTrip": "return_trip",
65}
66
4867
4968# ---------- row ↔ camelCase helpers ----------
5069
524543
525544 if update_fields:
526545 # Map camelCase keys to snake_case for DB columns
527 _camel_map = {"pickupAddress":"pickup_address","dropoffAddress":"dropoff_address","totalPrice":"total_price","updatedAt":"updated_at","createdAt":"created_at","serviceType":"service_type","departureFlightNumber":"departure_flight_number","departureTime":"departure_time","arrivalFlightNumber":"arrival_flight_number","arrivalTime":"arrival_time","vipPickup":"vip_pickup","oversizedLuggage":"oversized_luggage","returnTrip":"return_trip"}
528 _mapped = {_camel_map.get(k, k): v for k, v in update_fields.items()}
546 _mapped = {CAMEL_TO_SNAKE.get(k, k): v for k, v in update_fields.items()}
529547 await _pg_update("bookings", _mapped, "id", booking_id)
530548
531549 logger.info(f"Confirmations resent for booking {booking['booking_ref']}: {', '.join(messages_sent)}")
720738 </div>
721739 </div>
722740 """
723 from utils import send_email
724741 send_email(booking['email'], email_subject, email_body)
725742 email_sent = True
726743 except Exception as e:
729746 # Send SMS with payment link
730747 sms_sent = False
731748 try:
732 from utils import send_sms
733749 sms_message = f"""Hibiscus to Airport - Payment Request
734750
735751Booking: {booking.get('booking_ref', 'N/A')}
9861002 if not token:
9871003 raise HTTPException(status_code=401, detail="Not authenticated")
9881004
989 from auth import decode_token
9901005 payload = decode_token(token)
9911006 if not payload:
9921007 raise HTTPException(status_code=401, detail="Invalid token")
10591074 }
10601075
10611076 if existing:
1062 # TODO-MIGRATE: seo_pages.update_one
1063 _camel_map = {"pickupAddress":"pickup_address","dropoffAddress":"dropoff_address","totalPrice":"total_price","updatedAt":"updated_at","createdAt":"created_at"}
1064 _mapped = {_camel_map.get(k, k): v for k, v in page_doc.items()}
1077 _mapped = {CAMEL_TO_SNAKE.get(k, k): v for k, v in page_doc.items()}
10651078 await _pg_update("seo_pages", _mapped, "page_slug", seo_data.page_slug)
10661079 logger.info(f"SEO page updated: {seo_data.page_slug}")
10671080 else:
13371350 # Build update dict, excluding None values
13381351 update_data = {k: v for k, v in booking_update.model_dump().items() if v is not None}
13391352 update_data["updatedAt"] = datetime.utcnow().isoformat()
1340
1341 _camel_map = {"pickupAddress":"pickup_address","dropoffAddress":"dropoff_address","totalPrice":"total_price","updatedAt":"updated_at","createdAt":"created_at"}
1342 _mapped = {_camel_map.get(k, k): v for k, v in update_data.items()}
1353
1354 _mapped = {CAMEL_TO_SNAKE.get(k, k): v for k, v in update_data.items()}
13431355 await _pg_update("bookings", _mapped, "id", booking_id)
13441356
13451357 # Get updated booking
13651377 pool = await get_pool()
13661378 row = await pool.fetchrow("SELECT * FROM bookings WHERE id = $1", booking_id)
13671379 current_booking = row_to_booking(row)
1368
1369 _camel_map = {"pickupAddress":"pickup_address","dropoffAddress":"dropoff_address","totalPrice":"total_price","updatedAt":"updated_at","createdAt":"created_at"}
1370 _mapped = {_camel_map.get(k, k): v for k, v in update_data.items()}
1380
1381 _mapped = {CAMEL_TO_SNAKE.get(k, k): v for k, v in update_data.items()}
13711382 await _pg_update("bookings", _mapped, "id", booking_id)
13721383
13731384 # If status changed to confirmed, sync to Google Calendar
17321743 update_data = {k: v for k, v in driver_update.model_dump().items() if v is not None}
17331744 update_data["updatedAt"] = datetime.utcnow().isoformat()
17341745
1735 _camel_map = {"pickupAddress":"pickup_address","dropoffAddress":"dropoff_address","totalPrice":"total_price","updatedAt":"updated_at","createdAt":"created_at"}
1736 _mapped = {_camel_map.get(k, k): v for k, v in update_data.items()}
1746 _mapped = {CAMEL_TO_SNAKE.get(k, k): v for k, v in update_data.items()}
17371747 await _pg_update("drivers", _mapped, "id", driver_id)
17381748
17391749 pool = await get_pool()
21532163 </div>
21542164 """
21552165
2156 from utils import send_email
21572166 send_email(driver_email, subject, email_body)
21582167 logger.info(f"Job notification email sent to driver {driver_name} at {driver_email}")
21592168
21692178Click to accept/decline:
21702179{accept_url}"""
21712180
2172 from utils import send_sms
21732181 send_sms(driver_phone, sms_message)
21742182 logger.info(f"Job notification SMS sent to driver {driver_name} at {driver_phone}")
21752183
22592267
22602268 # Notify admin
22612269 admin_email = os.environ.get('ADMIN_EMAIL', 'bookings@bookaride.co.nz')
2262 from utils import send_email
22632270 send_email(
22642271 admin_email,
22652272 f"Driver ACCEPTED: {booking.get('booking_ref')}",
22802287
22812288 # Notify admin
22822289 admin_email = os.environ.get('ADMIN_EMAIL', 'bookings@bookaride.co.nz')
2283 from utils import send_email
22842290 send_email(
22852291 admin_email,
22862292 f"Driver DECLINED: {booking.get('booking_ref')}",
30443050 </div>
30453051 """
30463052
3047 from utils import send_email, send_sms
30483053 send_email(booking['email'], subject, body)
30493054
30503055 # Send reminder SMS
Modifiedapi/_shared/utils.py+2−3View fileUnifiedSplit
55from datetime import datetime
66import vobject
77import uuid
8from icalendar import Calendar, Event
9from pytz import timezone
810from db import get_pool
911
1012# iCloud/CardDAV Configuration
216218 logger.error(f"Error generating calendar invite: {str(e)}")
217219 return None
218220
219from icalendar import Calendar, Event
220from pytz import timezone
221
222221logger = logging.getLogger(__name__)
223222
224223# Google Maps Distance Calculator
Modifiedapi/index.py+1−1View fileUnifiedSplit
305305 return {"ok": True, "urls_submitted": len(urls), "status": resp.status_code}
306306 except Exception as e:
307307 logger.error(f"IndexNow submit error: {e}")
308 return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
308 return JSONResponse({"ok": False, "error": "Failed to submit URLs"}, status_code=500)
Deletedbackend/__init__.py+0−1View fileUnifiedSplit
1# backend package marker
Deletedbackend/admin_routes.py+0−326View fileUnifiedSplit
1# backend/admin_routes.py
2# FINISH_TODAY_B_ADMIN_LOGIN
3
4import os
5import logging
6from datetime import datetime
7from fastapi import APIRouter, Request, Response, Form
8from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
9
10logger = logging.getLogger(__name__)
11
12router = APIRouter()
13
14ADMIN_COOKIE = "d8_admin"
15ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "").strip()
16
17def _is_authed(req: Request) -> bool:
18 # allow either cookie or header for automation
19 if ADMIN_API_KEY == "":
20 return False
21 h = (req.headers.get("X-Admin-Key") or "").strip()
22 if h and h == ADMIN_API_KEY:
23 return True
24 c = (req.cookies.get(ADMIN_COOKIE) or "").strip()
25 return c == ADMIN_API_KEY
26
27def _require(req: Request):
28 if not _is_authed(req):
29 return False
30 return True
31
32@router.get("/admin/login", response_class=HTMLResponse)
33def admin_login_get():
34 return HTMLResponse("""<!doctype html>
35<html>
36<head>
37 <meta charset="utf-8" />
38 <meta name="viewport" content="width=device-width,initial-scale=1" />
39 <title>Edmund Admin Login</title>
40 <style>
41 body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:24px; max-width:820px; margin:0 auto;}
42 .card{border:1px solid #e5e7eb; border-radius:14px; padding:18px;}
43 input{width:100%; padding:12px; border-radius:10px; border:1px solid #d1d5db; margin-top:8px;}
44 button{margin-top:12px; padding:12px 14px; border-radius:10px; border:0; background:#111827; color:#fff; cursor:pointer;}
45 .hint{color:#6b7280; font-size:13px; margin-top:10px;}
46 </style>
47</head>
48<body>
49 <h1>Edmund Admin</h1>
50 <div class="card">
51 <form method="post" action="/admin/login">
52 <label>Admin Key</label>
53 <input name="key" type="password" placeholder="paste ADMIN_API_KEY" autocomplete="current-password" />
54 <button type="submit">Login</button>
55 <div class="hint">Uses ADMIN_API_KEY from Render env. Sets a cookie for this browser.</div>
56 </form>
57 </div>
58</body>
59</html>""")
60
61@router.post("/admin/login")
62def admin_login_post(key: str = Form(...)):
63 k = (key or "").strip()
64 if ADMIN_API_KEY == "" or k != ADMIN_API_KEY:
65 return HTMLResponse("<h3>401 Unauthorized</h3><p>Key mismatch.</p><p><a href='/admin/login'>Back</a></p>", status_code=401)
66 resp = RedirectResponse(url="/admin", status_code=302)
67 resp.set_cookie(key=ADMIN_COOKIE, value=ADMIN_API_KEY, httponly=True, samesite="lax", secure=True)
68 return resp
69
70@router.get("/admin/logout")
71def admin_logout():
72 resp = RedirectResponse(url="/admin/login", status_code=302)
73 resp.delete_cookie(ADMIN_COOKIE)
74 return resp
75
76@router.get("/admin", response_class=HTMLResponse)
77def admin_shell(req: Request):
78 if not _require(req):
79 return RedirectResponse(url="/admin/login", status_code=302)
80
81 return HTMLResponse("""<!doctype html>
82<html>
83<head>
84 <meta charset="utf-8" />
85 <meta name="viewport" content="width=device-width,initial-scale=1" />
86 <title>Edmund Panel</title>
87 <style>
88 body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; margin:0;}
89 header{display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid #e5e7eb;}
90 .tabs{display:flex; gap:10px; padding:10px 18px; border-bottom:1px solid #e5e7eb;}
91 .tab{padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb; background:#fff; cursor:pointer;}
92 .tab.active{background:#111827; color:#fff; border-color:#111827;}
93 main{padding:0; height:calc(100vh - 110px);}
94 iframe{width:100%; height:100%; border:0;}
95 .right a{color:#111827; text-decoration:none; font-size:14px;}
96 .meta{color:#6b7280; font-size:13px;}
97 </style>
98</head>
99<body>
100 <header>
101 <div>
102 <div style="font-weight:700;">Edmund Panel</div>
103 <div class="meta">Admin + Cockpit + Booking Form Editor</div>
104 </div>
105 <div class="right"><a href="/admin/logout">Logout</a></div>
106 </header>
107
108 <div class="tabs">
109 <button class="tab active" data-url="/admin/bookings-view">Bookings</button>
110 <button class="tab" data-url="/admin/cockpit">Cockpit</button>
111 <button class="tab" data-url="/admin/booking-form">Booking Form</button>
112 <button class="tab" data-url="/admin/status">Status</button>
113 </div>
114
115 <main>
116 <iframe id="frame" src="/admin/bookings-view"></iframe>
117 </main>
118
119<script>
120 const tabs=[...document.querySelectorAll('.tab')];
121 const frame=document.getElementById('frame');
122 tabs.forEach(t=>{
123 t.addEventListener('click', ()=>{
124 tabs.forEach(x=>x.classList.remove('active'));
125 t.classList.add('active');
126 frame.src = t.dataset.url;
127 });
128 });
129</script>
130</body>
131</html>""")
132
133@router.get("/admin/bookings-view", response_class=HTMLResponse)
134def admin_bookings_view(req: Request):
135 if not _require(req):
136 return RedirectResponse(url="/admin/login", status_code=302)
137
138 return HTMLResponse("""<!doctype html>
139<html>
140<head>
141 <meta charset="utf-8" />
142 <meta name="viewport" content="width=device-width,initial-scale=1" />
143 <title>Bookings</title>
144 <style>
145 body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:18px; margin:0;}
146 h2{margin:0 0 6px 0;}
147 .meta{color:#6b7280; font-size:13px; margin-bottom:14px;}
148 .row-bar{display:flex; gap:10px; flex-wrap:wrap; margin-bottom:14px; align-items:center;}
149 button{padding:8px 14px; border-radius:10px; border:1px solid #e5e7eb; background:#111827; color:#fff; cursor:pointer; font-size:13px;}
150 input{padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; font-size:13px;}
151 table{width:100%; border-collapse:collapse; font-size:13px;}
152 th{background:#f8fafc; text-align:left; padding:10px 8px; border-bottom:2px solid #e5e7eb; white-space:nowrap;}
153 td{padding:8px; border-bottom:1px solid #f1f5f9; vertical-align:top;}
154 tr:hover td{background:#f8fafc;}
155 .badge{display:inline-block; padding:3px 8px; border-radius:8px; font-size:11px; font-weight:600;}
156 .badge-pending{background:#fef3c7; color:#92400e;}
157 .badge-confirmed{background:#d1fae5; color:#065f46;}
158 .badge-cancelled{background:#fee2e2; color:#991b1b;}
159 .badge-paid{background:#d1fae5; color:#065f46;}
160 .badge-unpaid{background:#fee2e2; color:#991b1b;}
161 .stats{display:flex; gap:12px; flex-wrap:wrap; margin-bottom:14px;}
162 .stat{padding:12px 16px; border:1px solid #e5e7eb; border-radius:12px; min-width:120px;}
163 .stat-val{font-size:22px; font-weight:700;}
164 .stat-label{font-size:11px; color:#6b7280; margin-top:2px;}
165 #error{color:#dc2626; margin-top:10px; display:none;}
166 .empty{text-align:center; padding:40px; color:#6b7280;}
167 </style>
168</head>
169<body>
170 <h2>Bookings</h2>
171 <div class="meta">Live from database. Auto-refreshes every 30s.</div>
172
173 <div class="stats" id="stats"></div>
174
175 <div class="row-bar">
176 <input id="search" type="text" placeholder="Search name, email, ref..." oninput="applyFilter()" />
177 <select id="statusFilter" onchange="applyFilter()" style="padding:8px 12px; border-radius:10px; border:1px solid #d1d5db; font-size:13px;">
178 <option value="all">All statuses</option>
179 <option value="pending">Pending</option>
180 <option value="confirmed">Confirmed</option>
181 <option value="cancelled">Cancelled</option>
182 </select>
183 <button onclick="loadBookings()">Refresh</button>
184 </div>
185
186 <div id="error"></div>
187 <div id="table-wrap"></div>
188
189<script>
190let ALL = [];
191
192async function loadBookings(){
193 const wrap = document.getElementById('table-wrap');
194 const err = document.getElementById('error');
195 err.style.display='none';
196 wrap.innerHTML = '<div class="empty">Loading bookings...</div>';
197 try {
198 const r = await fetch('/api/admin/bookings-list?ts='+Date.now());
199 if(!r.ok) throw new Error('HTTP '+r.status);
200 const data = await r.json();
201 ALL = data.items || [];
202 renderStats();
203 applyFilter();
204 } catch(e){
205 err.textContent = 'Failed to load bookings: '+String(e);
206 err.style.display = 'block';
207 wrap.innerHTML = '<div class="empty">Could not load bookings.</div>';
208 }
209}
210
211function renderStats(){
212 const s = document.getElementById('stats');
213 const total = ALL.length;
214 const pending = ALL.filter(b=>b.status==='pending').length;
215 const confirmed = ALL.filter(b=>b.status==='confirmed').length;
216 const revenue = ALL.filter(b=>b.payment_status==='paid').reduce((s,b)=>s+(b.totalPrice||0),0);
217 s.innerHTML = `
218 <div class="stat"><div class="stat-val">${total}</div><div class="stat-label">Total</div></div>
219 <div class="stat"><div class="stat-val">${pending}</div><div class="stat-label">Pending</div></div>
220 <div class="stat"><div class="stat-val">${confirmed}</div><div class="stat-label">Confirmed</div></div>
221 <div class="stat"><div class="stat-val">$${revenue.toFixed(0)}</div><div class="stat-label">Revenue (paid)</div></div>
222 `;
223}
224
225function applyFilter(){
226 const term = (document.getElementById('search').value||'').toLowerCase();
227 const status = document.getElementById('statusFilter').value;
228 let list = ALL;
229 if(status!=='all') list = list.filter(b=>b.status===status);
230 if(term) list = list.filter(b=>
231 (b.name||'').toLowerCase().includes(term) ||
232 (b.email||'').toLowerCase().includes(term) ||
233 (b.phone||'').toLowerCase().includes(term) ||
234 (b.booking_ref||'').toLowerCase().includes(term) ||
235 (b.pickupAddress||'').toLowerCase().includes(term) ||
236 (b.dropoffAddress||'').toLowerCase().includes(term)
237 );
238 renderTable(list);
239}
240
241function badge(val, type){
242 const cls = type==='status'
243 ? (val==='confirmed'?'badge-confirmed':val==='cancelled'?'badge-cancelled':'badge-pending')
244 : (val==='paid'?'badge-paid':'badge-unpaid');
245 return '<span class="badge '+cls+'">'+(val||'n/a')+'</span>';
246}
247
248function renderTable(list){
249 const wrap = document.getElementById('table-wrap');
250 if(!list.length){
251 wrap.innerHTML = '<div class="empty">No bookings found.</div>';
252 return;
253 }
254 let html = '<table><thead><tr>';
255 html += '<th>Ref</th><th>Date</th><th>Time</th><th>Customer</th><th>Phone</th>';
256 html += '<th>Pickup</th><th>Dropoff</th><th>Pax</th><th>Price</th>';
257 html += '<th>Status</th><th>Payment</th>';
258 html += '</tr></thead><tbody>';
259 for(const b of list){
260 html += '<tr>';
261 html += '<td><b>'+(b.booking_ref||'-')+'</b></td>';
262 html += '<td>'+(b.date||'-')+'</td>';
263 html += '<td>'+(b.time||'-')+'</td>';
264 html += '<td>'+(b.name||'-')+'<br><span style="color:#6b7280;font-size:11px;">'+(b.email||'')+'</span></td>';
265 html += '<td>'+(b.phone||'-')+'</td>';
266 html += '<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;">'+(b.pickupAddress||'-')+'</td>';
267 html += '<td style="max-width:160px;overflow:hidden;text-overflow:ellipsis;">'+(b.dropoffAddress||'-')+'</td>';
268 html += '<td>'+(b.passengers||'-')+'</td>';
269 html += '<td>$'+(b.totalPrice||b.pricing?.totalPrice||0)+'</td>';
270 html += '<td>'+badge(b.status,'status')+'</td>';
271 html += '<td>'+badge(b.payment_status,'payment')+'</td>';
272 html += '</tr>';
273 }
274 html += '</tbody></table>';
275 wrap.innerHTML = html;
276}
277
278loadBookings();
279setInterval(loadBookings, 30000);
280</script>
281</body>
282</html>""")
283
284@router.get("/api/admin/bookings-list")
285async def admin_bookings_list(req: Request):
286 """Fetch bookings from PostgreSQL for the server-rendered admin panel."""
287 if not _require(req):
288 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
289 try:
290 from db import get_pool
291 import json
292 pool = await get_pool()
293 rows = await pool.fetch("SELECT * FROM bookings ORDER BY created_at DESC LIMIT 500")
294 items = []
295 for row in rows:
296 d = dict(row)
297 # Map snake_case DB columns to camelCase for frontend
298 d["pickupAddress"] = d.pop("pickup_address", None)
299 d["dropoffAddress"] = d.pop("dropoff_address", None)
300 d["totalPrice"] = float(d.pop("total_price", 0) or 0)
301 d["serviceType"] = d.pop("service_type", None)
302 d["createdAt"] = d.pop("created_at", None)
303 d["updatedAt"] = d.pop("updated_at", None)
304 d["vipPickup"] = d.pop("vip_pickup", False)
305 d["oversizedLuggage"] = d.pop("oversized_luggage", False)
306 d["returnTrip"] = d.pop("return_trip", False)
307 d["departureFlightNumber"] = d.pop("departure_flight_number", None)
308 d["departureTime"] = d.pop("departure_time", None)
309 d["arrivalFlightNumber"] = d.pop("arrival_flight_number", None)
310 d["arrivalTime"] = d.pop("arrival_time", None)
311 d["additionalPickups"] = d.pop("additional_pickups", [])
312 # Convert Decimal to float for JSON serialisation
313 for k in ["driver_payout", "return_driver_payout"]:
314 if d.get(k) is not None:
315 d[k] = float(d[k])
316 items.append(d)
317 return JSONResponse({"ok": True, "count": len(items), "items": items})
318 except Exception as e:
319 logger.error(f"admin_bookings_list error: {e}")
320 return JSONResponse({"ok": False, "error": str(e), "items": []})
321
322@router.get("/admin/status")
323def admin_status(req: Request):
324 if not _require(req):
325 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
326 return JSONResponse({"ok": True, "utc": datetime.utcnow().isoformat() + "Z"})
Deletedbackend/agent_cockpit.html+0−134View fileUnifiedSplit
1<!doctype html>
2<html>
3<head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>Agent Cockpit — Hibiscus</title>
7 <style>
8 body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; background:#0b0b0f; color:#fff; }
9 .wrap { max-width: 1100px; margin: 0 auto; padding: 20px; }
10 .card { background:#141420; border:1px solid #2a2a3b; border-radius:14px; padding:16px; margin-top:14px; }
11 label { display:block; font-size:12px; opacity:.8; margin-bottom:6px; }
12 input, select, textarea { width:100%; box-sizing:border-box; padding:10px; border-radius:10px; border:1px solid #2a2a3b; background:#0f0f18; color:#fff; }
13 textarea { min-height: 140px; resize: vertical; }
14 button { padding:10px 12px; border-radius:10px; border:1px solid #2a2a3b; background:#1e1e2f; color:#fff; cursor:pointer; }
15 button:hover { background:#26263d; }
16 .row { display:flex; gap:10px; flex-wrap:wrap; }
17 .row > div { flex:1; min-width:220px; }
18 pre { white-space: pre-wrap; word-break: break-word; background:#0f0f18; border:1px solid #2a2a3b; padding:12px; border-radius:12px; }
19 .muted { opacity:.7; font-size:12px; }
20 </style>
21</head>
22<body>
23 <div class="wrap">
24 <h1 style="margin:0 0 6px 0;">Agent Cockpit</h1>
25 <div class="muted">Mic works in Chrome/Edge via Speech Recognition. This cockpit calls your backend at /api/agents/*.</div>
26
27 <div class="card">
28 <div class="row">
29 <div>
30 <label>Admin Token (X-Admin-Token header)</label>
31 <input id="token" placeholder="Optional (recommended). Stored in this browser." />
32 </div>
33 <div>
34 <label>Agent</label>
35 <select id="agent">
36 <option value="01_dispatcher">01 Dispatcher</option>
37 <option value="02_api_engineer">02 API Engineer</option>
38 <option value="03_db_engineer">03 DB Engineer</option>
39 <option value="04_payments_stripe">04 Payments/Stripe</option>
40 <option value="05_notifications">05 Notifications</option>
41 <option value="06_ops_reliability">06 Ops/Reliability</option>
42 <option value="07_frontend_wiring">07 Frontend Wiring</option>
43 <option value="08_security">08 Security</option>
44 <option value="09_release_manager">09 Release Manager</option>
45 </select>
46 </div>
47 <div style="display:flex; align-items:end; gap:10px;">
48 <button id="mic">🎙️ Start Mic</button>
49 <button id="send">Run Agent</button>
50 </div>
51 </div>
52
53 <div style="margin-top:12px;">
54 <label>Your instruction</label>
55 <textarea id="msg" placeholder="Speak or type what you want the agent to do..."></textarea>
56 <div class="muted" style="margin-top:8px;">
57 Tip: Start with “What’s blocking bookings?” or “Fix Mongo on Render using my current env vars.”
58 </div>
59 </div>
60 </div>
61
62 <div class="card">
63 <label>Agent output</label>
64 <pre id="out">Ready.</pre>
65 </div>
66 </div>
67
68<script>
69 const tokenEl = document.getElementById("token");
70 const agentEl = document.getElementById("agent");
71 const msgEl = document.getElementById("msg");
72 const outEl = document.getElementById("out");
73 const micBtn = document.getElementById("mic");
74 const sendBtn = document.getElementById("send");
75
76 tokenEl.value = localStorage.getItem("agent_token") || "";
77 tokenEl.addEventListener("input", () => localStorage.setItem("agent_token", tokenEl.value));
78
79 let rec = null;
80 function supportsSpeech() { return !!(window.SpeechRecognition || window.webkitSpeechRecognition); }
81
82 micBtn.addEventListener("click", () => {
83 if (!supportsSpeech()) { alert("Speech Recognition not supported in this browser. Use Chrome/Edge."); return; }
84 if (rec) { rec.stop(); rec = null; micBtn.textContent = "🎙️ Start Mic"; return; }
85
86 const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
87 rec = new SR();
88 rec.lang = "en-NZ";
89 rec.interimResults = true;
90 rec.continuous = true;
91
92 rec.onresult = (e) => {
93 let finalText = "";
94 for (let i = e.resultIndex; i < e.results.length; i++) {
95 const t = e.results[i][0].transcript;
96 if (e.results[i].isFinal) finalText += t + " ";
97 }
98 if (finalText) msgEl.value = (msgEl.value + " " + finalText).trim();
99 };
100 rec.onend = () => { rec = null; micBtn.textContent = "🎙️ Start Mic"; };
101 rec.start();
102 micBtn.textContent = "⏹ Stop Mic";
103 });
104
105 async function run() {
106 outEl.textContent = "Running...";
107 const agentId = agentEl.value;
108 const message = msgEl.value.trim();
109 if (!message) { outEl.textContent = "Type or speak an instruction first."; return; }
110
111 const headers = { "Content-Type": "application/json" };
112 const token = (tokenEl.value || "").trim();
113 if (token) headers["X-Admin-Token"] = token;
114
115 const body = JSON.stringify({ agentId, message, context: { page: "agent-cockpit" } });
116
117 const res = await fetch("/api/api/agents/run", { method:"POST", headers, body });
118 const text = await res.text();
119 let data;
120 try { data = JSON.parse(text); } catch { data = text; }
121
122 if (!res.ok) {
123 outEl.textContent = "HTTP " + res.status + "\n" + (typeof data === "string" ? data : JSON.stringify(data, null, 2));
124 return;
125 }
126 outEl.textContent = (typeof data === "string" ? data : JSON.stringify(data, null, 2));
127 }
128
129 sendBtn.addEventListener("click", () => run().catch(e => outEl.textContent = String(e)));
130</script>
131</body>
132</html>
133
134
Deletedbackend/agent_routes.py+0−114View fileUnifiedSplit
1# ===== HIBISCUS_COCKPIT_002_20260201_190341 =====
2from fastapi import APIRouter, Request
3# cockpit_router is mounted separately by server.py
4from fastapi.responses import HTMLResponse, JSONResponse
5from pydantic import BaseModel
6from typing import Any, Dict, Optional
7import time, uuid, os
8
9router = APIRouter()
10_JOBS = [] # newest first
11
12class CockpitRun(BaseModel):
13 action: str
14 prompt: Optional[str] = ""
15 meta: Optional[Dict[str, Any]] = None
16
17def _now(): return int(time.time())
18
19@router.get("/__cockpit_stamp__")
20def cockpit_stamp():
21 return {"ok": True, "stamp": "HIBISCUS_COCKPIT_002_20260201_190341", "ts": _now()}
22
23@router.get("/agent-cockpit", response_class=HTMLResponse)
24def agent_cockpit():
25 html = r"""
26<!doctype html><html><head><meta charset="utf-8"/>
27<meta name="viewport" content="width=device-width,initial-scale=1"/>
28<title>Hibiscus Cockpit</title>
29<style>
30body{margin:0;font-family:system-ui;background:#070A12;color:#fff;display:flex;justify-content:center;padding:24px}
31.card{width:min(980px,96vw);background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
32border-radius:22px;padding:18px 18px 14px}
33h1{margin:10px 0 14px;text-align:center;font-size:40px}
34.bar{display:flex;gap:10px;align-items:center;background:rgba(0,0,0,.28);border:1px solid rgba(255,255,255,.10);
35border-radius:999px;padding:12px 12px}
36input{flex:1;background:transparent;border:0;outline:none;color:#fff;font-size:16px;padding:6px 8px}
37button{border:0;border-radius:999px;padding:10px 18px;font-weight:700;color:#fff;cursor:pointer;
38background:linear-gradient(180deg,#60A5FA,#3B82F6)}
39.grid{display:grid;grid-template-columns:1.1fr .9fr;gap:14px;margin-top:14px}
40.box{background:rgba(0,0,0,.22);border:1px solid rgba(255,255,255,.10);border-radius:18px;padding:12px}
41.row{display:flex;justify-content:space-between;gap:10px;padding:10px;border-radius:14px}
42.row:hover{background:rgba(255,255,255,.04)}
43.small{color:rgba(255,255,255,.55);font-size:12px}
44.jobs{max-height:240px;overflow:auto;display:flex;flex-direction:column;gap:8px}
45.job{background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.10);border-radius:14px;padding:10px}
46</style></head>
47<body>
48<div class="card" data-stamp="HIBISCUS_COCKPIT_002_20260201_190341">
49 <div class="small">api.hibiscustoairport.co.nz • HIBISCUS_COCKPIT_002_20260201_190341</div>
50 <h1>What would you like to run?</h1>
51 <div class="bar">
52 <input id="p" placeholder="repair failing CI, build patch, run SEO..."/>
53 <button id="go">GENERATE</button>
54 </div>
55 <div class="grid">
56 <div class="box">
57 <div class="row"><div><b>Repair Pack</b><div class="small">9 agents</div></div><button onclick="run('repair_pack')">Run</button></div>
58 <div class="row"><div><b>Patch Builder</b><div class="small">unified diff</div></div><button onclick="run('patch_builder')">Run</button></div>
59 <div class="row"><div><b>PR Dispatch</b><div class="small">Agent PR workflow</div></div><button onclick="run('dispatch_pr')">Run</button></div>
60 <div class="row"><div><b>SEO / Website</b><div class="small">only if endpoint exists</div></div><button onclick="run('seo_run')">Run</button></div>
61 </div>
62 <div class="box">
63 <b>Activity</b>
64 <div class="jobs" id="jobs"></div>
65 </div>
66 </div>
67</div>
68<script>
69async function api(path, opts){
70 const u = path + (path.includes('?')?'&':'?') + 'ts=' + Math.floor(Date.now()/1000);
71 const r = await fetch(u, opts||{});
72 const t = await r.text();
73 let j=null; try{ j=JSON.parse(t);}catch{}
74 return {ok:r.ok, status:r.status, json:j, text:t};
75}
76function render(list){
77 const root=document.getElementById('jobs'); root.innerHTML='';
78 if(!list || !list.length){ root.innerHTML='<div class="job"><b>No jobs yet</b><div class="small">Run something.</div></div>'; return; }
79 for(const x of list){
80 const d=document.createElement('div'); d.className='job';
81 d.innerHTML = '<b>'+x.kind+'</b> • '+x.status+'<div class="small">'+JSON.stringify(x.payload).slice(0,180)+'</div>';
82 root.appendChild(d);
83 }
84}
85async function refresh(){
86 const s = await api('/api/cockpit/state');
87 if(s.ok && s.json) render(s.json.jobs||[]);
88}
89async function run(action){
90 const prompt = (document.getElementById('p').value||'');
91 const payload = {action, prompt, meta:{from:'cockpit'}};
92 const r = await api('/api/cockpit/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});
93 await refresh();
94 if(!r.ok) alert('Run failed: '+r.status+'\\n'+(r.text||''));
95}
96document.getElementById('go').onclick=()=>run('repair_pack');
97refresh(); setInterval(refresh, 5000);
98</script>
99</body></html>
100"""
101 return HTMLResponse(html)
102
103@router.get("/api/cockpit/state")
104def state():
105 return JSONResponse({"ok": True, "ts": _now(), "jobs": _JOBS[:15], "jobsCount": len(_JOBS),
106 "agents": {"ping": "/api/agents/ping", "repair": "/api/agents/repair", "patchBuilder": "/api/agents/patch-builder"}})
107
108@router.post("/api/cockpit/run")
109async def run(body: CockpitRun, request: Request):
110 job = {"id": str(uuid.uuid4()), "ts": _now(), "kind": body.action, "payload": {"prompt": body.prompt or "", "meta": body.meta or {}}, "status":"queued"}
111 _JOBS.insert(0, job); del _JOBS[50:]
112 return JSONResponse({"ok": True, "job": job})
113
114# cockpit_router is now included by server.py directly
Deletedbackend/agent_runtime.py+0−68View fileUnifiedSplit
1import os
2from pathlib import Path
3from typing import Dict, Any
4
5AGENTS_DIR = Path(__file__).resolve().parent / "agents"
6
7def _read_agent_md(agent_id: str) -> str:
8 name = agent_id if agent_id.endswith(".md") else f"{agent_id}.md"
9 p = AGENTS_DIR / name
10 if not p.exists():
11 raise FileNotFoundError(f"Missing agent prompt: {p}")
12 return p.read_text(encoding="utf-8")
13
14def run_agent_local_stub(agent_id: str, user_message: str, context: Dict[str, Any]) -> Dict[str, Any]:
15 prompt = _read_agent_md(agent_id)
16 return {
17 "ok": True,
18 "mode": "stub",
19 "agentId": agent_id,
20 "note": "OPENAI_API_KEY not configured; returning a structured stub response.",
21 "promptPreview": prompt[:400],
22 "userMessage": user_message,
23 "context": context,
24 "result": {
25 "summary": "Stub run. Configure OPENAI_API_KEY in Render to enable real agent reasoning.",
26 "nextSteps": [
27 "Set OPENAI_API_KEY in Render Environment (backend service).",
28 "Optionally set OPENAI_MODEL (default in code: gpt-5-mini).",
29 "Re-run agent from the cockpit."
30 ],
31 },
32 }
33
34def run_agent_openai(agent_id: str, user_message: str, context: Dict[str, Any]) -> Dict[str, Any]:
35 api_key = os.getenv("OPENAI_API_KEY", "").strip()
36 if not api_key:
37 return run_agent_local_stub(agent_id, user_message, context)
38
39 from openai import OpenAI
40
41 # Safer default than "gpt-5" for many accounts; override via OPENAI_MODEL in Render.
42 model = (os.getenv("OPENAI_MODEL") or "gpt-5-mini").strip()
43 system = _read_agent_md(agent_id)
44
45 client = OpenAI(api_key=api_key)
46
47 resp = client.responses.create(
48 model=model,
49 input=[
50 {"role": "system", "content": system},
51 {"role": "user", "content": f"CONTEXT (json): {context}\\n\\nUSER: {user_message}"}
52 ],
53 max_output_tokens=1200,
54 )
55
56 text = ""
57 try:
58 text = resp.output_text
59 except Exception:
60 text = str(resp)
61
62 return {
63 "ok": True,
64 "mode": "openai",
65 "agentId": agent_id,
66 "model": model,
67 "resultText": text,
68 }
\ No newline at end of file
Deletedbackend/agents/01_dispatcher.md+0−7View fileUnifiedSplit
1# Agent 01 — Dispatcher
2You are the dispatcher. Your job:
3- Ask the user what outcome they want (brief).
4- Decide which specialist agent(s) should run next (02–09).
5- Produce a short run plan: steps, inputs needed, risks.
6- Never request secrets. Never output credentials.
7- Prefer minimal changes. Preserve existing UI/design.
\ No newline at end of file
Deletedbackend/agents/02_api_engineer.md+0−6View fileUnifiedSplit
1# Agent 02 — API Engineer
2You work on FastAPI routes, request/response validation, reliability.
3- Keep changes minimal.
4- Add diagnostics only if needed.
5- Do not break existing routes.
6- Never output secrets.
\ No newline at end of file
Deletedbackend/agents/03_db_engineer.md+0−5View fileUnifiedSplit
1# Agent 03 — DB Engineer
2You focus on MongoDB Atlas connectivity, indexing, schema hygiene.
3- Prefer environment-variable fixes first.
4- Never output secrets.
5- Suggest exact Render/Atlas steps, and minimal code changes.
\ No newline at end of file
Deletedbackend/agents/04_payments_stripe.md+0−5View fileUnifiedSplit
1# Agent 04 — Payments/Stripe
2You focus on Stripe checkout/webhook stability.
3- Never output secrets.
4- Add idempotency and signature verification guidance.
5- Keep existing flows intact.
\ No newline at end of file
Deletedbackend/agents/05_notifications.md+0−4View fileUnifiedSplit
1# Agent 05 — Notifications (Email/SMS)
2You focus on reminders and messaging routes.
3- Keep change surface minimal.
4- Never output secrets.
\ No newline at end of file
Deletedbackend/agents/06_ops_reliability.md+0−5View fileUnifiedSplit
1# Agent 06 — Ops/Reliability
2You focus on health checks, timeouts, logging, tracing.
3- Avoid noisy logs.
4- Never output secrets.
5- Prefer /api/health and controlled debug (no creds).
\ No newline at end of file
Deletedbackend/agents/07_frontend_wiring.md+0−4View fileUnifiedSplit
1# Agent 07 — Frontend Wiring
2You only touch frontend wiring if asked.
3- Preserve UI/design exactly.
4- Only change API base URLs, timeouts, payload types.
\ No newline at end of file
Deletedbackend/agents/08_security.md+0−5View fileUnifiedSplit
1# Agent 08 — Security
2You focus on safe defaults:
3- Never output secrets.
4- Require ADMIN_TOKEN for privileged operations.
5- Suggest password rotation if a secret was exposed.
\ No newline at end of file
Deletedbackend/agents/09_release_manager.md+0−4View fileUnifiedSplit
1# Agent 09 — Release Manager
2You produce a release checklist:
3- What changed, how to verify, rollback steps.
4- Keep it short and actionable.
\ No newline at end of file
Deletedbackend/auth.py+0−54View fileUnifiedSplit
1from datetime import datetime, timedelta
2from typing import Optional
3import logging
4import os
5from jose import JWTError, jwt
6from passlib.context import CryptContext
7from fastapi import Depends, HTTPException, status
8from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
9
10# Security
11pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
12security = HTTPBearer()
13
14SECRET_KEY = os.environ.get("JWT_SECRET_KEY", "")
15if not SECRET_KEY:
16 import secrets as _secrets
17 SECRET_KEY = _secrets.token_urlsafe(64)
18 logging.getLogger(__name__).warning("JWT_SECRET_KEY not set — using random key (tokens will not survive restarts)")
19ALGORITHM = "HS256"
20ACCESS_TOKEN_EXPIRE_HOURS = 24
21
22def verify_password(plain_password: str, hashed_password: str) -> bool:
23 return pwd_context.verify(plain_password, hashed_password)
24
25def get_password_hash(password: str) -> str:
26 return pwd_context.hash(password)
27
28def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
29 to_encode = data.copy()
30 if expires_delta:
31 expire = datetime.utcnow() + expires_delta
32 else:
33 expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
34 to_encode.update({"exp": expire})
35 encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
36 return encoded_jwt
37
38def decode_token(token: str):
39 try:
40 payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
41 return payload
42 except JWTError:
43 return None
44
45async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
46 token = credentials.credentials
47 payload = decode_token(token)
48 if payload is None:
49 raise HTTPException(
50 status_code=status.HTTP_401_UNAUTHORIZED,
51 detail="Invalid authentication credentials",
52 headers={"WWW-Authenticate": "Bearer"},
53 )
54 return payload
Deletedbackend/booking_routes.py+0−3354View fileUnifiedSplit
Large file (3,355 lines). Load full file
Deletedbackend/bookingform_routes.py+0−150View fileUnifiedSplit
1# backend/bookingform_routes.py
2# FINISH_TODAY_D_BOOKING_FORM_EDITOR
3
4import os
5import json
6from datetime import datetime
7from fastapi import APIRouter, Request
8from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
9from pydantic import BaseModel
10
11router = APIRouter()
12
13ADMIN_COOKIE = "d8_admin"
14ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "").strip()
15
16HERE = os.path.dirname(os.path.abspath(__file__))
17DATA_DIR = os.path.join(HERE, "data")
18CFG_PATH = os.path.join(DATA_DIR, "booking_form.json")
19
20DEFAULT_CFG = {
21 "version": 1,
22 "updatedUtc": None,
23 "fields": [
24 {"key":"fullName","label":"Full name","type":"text","required": True},
25 {"key":"phone","label":"Phone","type":"text","required": True},
26 {"key":"email","label":"Email","type":"email","required": True},
27 {"key":"pickup","label":"Pickup address","type":"text","required": True},
28 {"key":"dropoff","label":"Dropoff address","type":"text","required": True},
29 {"key":"pickupDate","label":"Pickup date","type":"date","required": True},
30 {"key":"pickupTime","label":"Pickup time","type":"time","required": True},
31 {"key":"flightNumber","label":"Flight number (optional)","type":"text","required": False},
32 {"key":"notes","label":"Notes (optional)","type":"textarea","required": False}
33 ]
34}
35
36def _ensure_default():
37 os.makedirs(DATA_DIR, exist_ok=True)
38 if not os.path.exists(CFG_PATH):
39 d = dict(DEFAULT_CFG)
40 d["updatedUtc"] = datetime.utcnow().isoformat() + "Z"
41 with open(CFG_PATH, "w", encoding="utf-8") as f:
42 json.dump(d, f, indent=2)
43
44def _load():
45 _ensure_default()
46 with open(CFG_PATH, "r", encoding="utf-8") as f:
47 return json.load(f)
48
49def _save(obj):
50 os.makedirs(DATA_DIR, exist_ok=True)
51 obj["updatedUtc"] = datetime.utcnow().isoformat() + "Z"
52 with open(CFG_PATH, "w", encoding="utf-8") as f:
53 json.dump(obj, f, indent=2)
54 return obj
55
56def _is_authed(req: Request) -> bool:
57 if ADMIN_API_KEY == "":
58 return False
59 h = (req.headers.get("X-Admin-Key") or "").strip()
60 if h and h == ADMIN_API_KEY:
61 return True
62 c = (req.cookies.get(ADMIN_COOKIE) or "").strip()
63 return c == ADMIN_API_KEY
64
65class SaveBody(BaseModel):
66 cfg: dict
67
68@router.get("/api/public/booking-form")
69def booking_form_public():
70 return JSONResponse(_load())
71
72@router.get("/api/admin/booking-form")
73def booking_form_admin_get(req: Request):
74 if not _is_authed(req):
75 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
76 return JSONResponse(_load())
77
78@router.post("/api/admin/booking-form")
79async def booking_form_admin_set(req: Request):
80 if not _is_authed(req):
81 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
82 body = await req.json()
83 cfg = body.get("cfg")
84 if not isinstance(cfg, dict):
85 return JSONResponse({"ok": False, "error": "cfg must be an object"}, status_code=400)
86 saved = _save(cfg)
87 return JSONResponse({"ok": True, "saved": saved})
88
89@router.get("/admin/booking-form", response_class=HTMLResponse)
90def booking_form_editor(req: Request):
91 if not _is_authed(req):
92 return RedirectResponse(url="/admin/login", status_code=302)
93
94 return HTMLResponse("""<!doctype html>
95<html>
96<head>
97 <meta charset="utf-8"/>
98 <meta name="viewport" content="width=device-width,initial-scale=1"/>
99 <title>Booking Form Editor</title>
100 <style>
101 body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:18px;}
102 textarea{width:100%; min-height:360px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
103 border:1px solid #d1d5db; border-radius:12px; padding:12px;}
104 button{margin-top:10px; padding:10px 12px; border-radius:10px; border:0; background:#111827; color:#fff; cursor:pointer;}
105 pre{background:#0b1020; color:#e5e7eb; padding:12px; border-radius:12px; overflow:auto;}
106 .row{display:flex; gap:10px; flex-wrap:wrap; margin-bottom:10px;}
107 .meta{color:#6b7280; font-size:13px;}
108 </style>
109</head>
110<body>
111 <h2>Booking Form Editor</h2>
112 <div class="meta">Edit JSON config. Save applies immediately. Public endpoint: <code>/api/public/booking-form</code></div>
113
114 <div class="row">
115 <button onclick="loadCfg()">Load</button>
116 <button onclick="saveCfg()">Save</button>
117 </div>
118
119 <textarea id="t"></textarea>
120
121 <h3>Result</h3>
122 <pre id="out">(none)</pre>
123
124<script>
125async function loadCfg(){
126 const out=document.getElementById('out');
127 out.textContent='Loading...';
128 const r = await fetch('/api/admin/booking-form?ts='+Date.now());
129 const j = await r.json();
130 document.getElementById('t').value = JSON.stringify(j, null, 2);
131 out.textContent = 'HTTP '+r.status;
132}
133async function saveCfg(){
134 const out=document.getElementById('out');
135 out.textContent='Saving...';
136 let cfg=null;
137 try{ cfg = JSON.parse(document.getElementById('t').value); }
138 catch(e){ out.textContent='JSON parse error: '+String(e); return; }
139 const r = await fetch('/api/admin/booking-form?ts='+Date.now(), {
140 method:'POST',
141 headers:{'content-type':'application/json'},
142 body: JSON.stringify({cfg})
143 });
144 const t = await r.text();
145 out.textContent='HTTP '+r.status+'\\n\\n'+t;
146}
147loadCfg();
148</script>
149</body>
150</html>""")
Deletedbackend/cockpit_routes.py+0−73View fileUnifiedSplit
1# backend/cockpit_routes.py
2# FINISH_TODAY_C_COCKPIT
3
4import os
5from datetime import datetime
6from fastapi import APIRouter, Request
7from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
8
9cockpit_router = APIRouter()
10
11ADMIN_COOKIE = "d8_admin"
12ADMIN_API_KEY = os.environ.get("ADMIN_API_KEY", "").strip()
13
14def _is_authed(req: Request) -> bool:
15 if ADMIN_API_KEY == "":
16 return False
17 h = (req.headers.get("X-Admin-Key") or "").strip()
18 if h and h == ADMIN_API_KEY:
19 return True
20 c = (req.cookies.get(ADMIN_COOKIE) or "").strip()
21 return c == ADMIN_API_KEY
22
23@cockpit_router.get("/admin/cockpit", response_class=HTMLResponse)
24def cockpit(req: Request):
25 if not _is_authed(req):
26 return RedirectResponse(url="/admin/login", status_code=302)
27
28 return HTMLResponse(f"""<!doctype html>
29<html>
30<head>
31 <meta charset="utf-8" />
32 <meta name="viewport" content="width=device-width,initial-scale=1" />
33 <title>Cockpit</title>
34 <style>
35 body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial; padding:18px;}}
36 .row{{display:flex; gap:10px; flex-wrap:wrap;}}
37 button{{padding:10px 12px; border-radius:10px; border:1px solid #e5e7eb; background:#111827; color:#fff; cursor:pointer;}}
38 pre{{background:#0b1020; color:#e5e7eb; padding:12px; border-radius:12px; overflow:auto;}}
39 .card{{border:1px solid #e5e7eb; border-radius:14px; padding:14px; margin-top:12px;}}
40 .meta{{color:#6b7280; font-size:13px;}}
41 </style>
42</head>
43<body>
44 <h2>Agent Cockpit</h2>
45 <div class="meta">Boot is green. This panel checks core endpoints and prepares agent automation.</div>
46
47 <div class="row" style="margin-top:10px;">
48 <button onclick="hit('/debug/stamp')">/debug/stamp</button>
49 <button onclick="hit('/api/agents/ping')">/api/agents/ping</button>
50 <button onclick="hit('/healthz')">/healthz</button>
51 </div>
52
53 <div class="card">
54 <div style="font-weight:700;">Output</div>
55 <pre id="out">(click a button)</pre>
56 </div>
57
58<script>
59async function hit(path){{
60 const out=document.getElementById('out');
61 out.textContent='Loading '+path+' ...';
62 try {{
63 const r = await fetch(path+'?ts='+(Date.now()));
64 const t = await r.text();
65 out.textContent = 'HTTP '+r.status+'\\n\\n'+t;
66 }} catch(e) {{
67 out.textContent = 'ERROR: '+String(e);
68 }}
69}}
70</script>
71
72</body>
73</html>""")
Deletedbackend/db.py+0−192View fileUnifiedSplit
1# backend/db.py
2# Neon PostgreSQL connection pool — import `get_pool` and use it everywhere.
3
4import os
5import logging
6import asyncpg
7
8logger = logging.getLogger(__name__)
9
10_pool: asyncpg.Pool | None = None
11
12DATABASE_URL = os.environ.get("DATABASE_URL", "")
13
14if not DATABASE_URL:
15 logger.warning("DATABASE_URL not set — database operations will fail at runtime")
16
17
18async def get_pool() -> asyncpg.Pool:
19 """Return (and lazily create) the shared connection pool."""
20 global _pool
21 if _pool is None:
22 if not DATABASE_URL:
23 raise RuntimeError("DATABASE_URL is not configured")
24 _pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10)
25 await _init_schema(_pool)
26 return _pool
27
28
29async def _init_schema(pool: asyncpg.Pool):
30 """Create tables if they don't exist yet."""
31 async with pool.acquire() as conn:
32 await conn.execute("""
33 CREATE TABLE IF NOT EXISTS bookings (
34 id TEXT PRIMARY KEY,
35 booking_ref TEXT UNIQUE NOT NULL,
36 name TEXT NOT NULL,
37 email TEXT NOT NULL,
38 phone TEXT NOT NULL,
39 pickup_address TEXT,
40 dropoff_address TEXT,
41 date TEXT,
42 time TEXT,
43 passengers TEXT DEFAULT '1',
44 notes TEXT,
45 service_type TEXT,
46 departure_flight_number TEXT,
47 departure_time TEXT,
48 arrival_flight_number TEXT,
49 arrival_time TEXT,
50 vip_pickup BOOLEAN DEFAULT FALSE,
51 oversized_luggage BOOLEAN DEFAULT FALSE,
52 return_trip BOOLEAN DEFAULT FALSE,
53 pricing JSONB,
54 total_price NUMERIC(10,2) DEFAULT 0,
55 status TEXT DEFAULT 'pending',
56 payment_status TEXT DEFAULT 'unpaid',
57 payment_method TEXT,
58 last_email_sent TEXT,
59 last_sms_sent TEXT,
60 payment_link_sent TEXT,
61 tracking_id TEXT,
62 tracking_status TEXT,
63 assigned_driver_id TEXT,
64 assigned_driver_name TEXT,
65 driver_payout NUMERIC(10,2),
66 driver_notes TEXT,
67 acceptance_token TEXT,
68 driver_accepted BOOLEAN,
69 driver_accepted_at TEXT,
70 driver_declined_at TEXT,
71 driver_decline_reason TEXT,
72 driver_assigned_at TEXT,
73 driver_location JSONB,
74 driver_eta_minutes INTEGER,
75 auto_dispatched BOOLEAN DEFAULT FALSE,
76 reminder_sent BOOLEAN DEFAULT FALSE,
77 reminder_sent_at TEXT,
78 return_driver_id TEXT,
79 return_driver_name TEXT,
80 return_driver_payout NUMERIC(10,2),
81 return_driver_notes TEXT,
82 return_acceptance_token TEXT,
83 return_driver_accepted BOOLEAN,
84 return_tracking_status TEXT,
85 return_driver_assigned_at TEXT,
86 google_calendar_event_id TEXT,
87 additional_pickups JSONB DEFAULT '[]'::jsonb,
88 created_at TEXT,
89 updated_at TEXT
90 );
91
92 CREATE TABLE IF NOT EXISTS deleted_bookings (
93 id TEXT PRIMARY KEY,
94 booking_ref TEXT,
95 name TEXT,
96 email TEXT,
97 phone TEXT,
98 pickup_address TEXT,
99 dropoff_address TEXT,
100 date TEXT,
101 time TEXT,
102 passengers TEXT,
103 notes TEXT,
104 service_type TEXT,
105 pricing JSONB,
106 total_price NUMERIC(10,2),
107 status TEXT,
108 payment_status TEXT,
109 tracking_id TEXT,
110 assigned_driver_name TEXT,
111 created_at TEXT,
112 updated_at TEXT,
113 deleted_at TEXT,
114 deleted_by TEXT,
115 booking_data JSONB
116 );
117
118 CREATE TABLE IF NOT EXISTS admins (
119 id TEXT PRIMARY KEY,
120 username TEXT UNIQUE NOT NULL,
121 password TEXT NOT NULL,
122 email TEXT,
123 created_at TEXT,
124 updated_at TEXT
125 );
126
127 CREATE TABLE IF NOT EXISTS password_resets (
128 id SERIAL PRIMARY KEY,
129 email TEXT NOT NULL,
130 token TEXT NOT NULL,
131 expires_at TEXT NOT NULL,
132 created_at TEXT
133 );
134
135 CREATE TABLE IF NOT EXISTS drivers (
136 id TEXT PRIMARY KEY,
137 name TEXT NOT NULL,
138 phone TEXT,
139 email TEXT,
140 vehicle TEXT,
141 license TEXT,
142 status TEXT DEFAULT 'active',
143 active BOOLEAN DEFAULT TRUE,
144 created_at TEXT,
145 updated_at TEXT
146 );
147
148 CREATE TABLE IF NOT EXISTS promo_codes (
149 id TEXT PRIMARY KEY,
150 code TEXT UNIQUE NOT NULL,
151 discount_type TEXT DEFAULT 'percentage',
152 discount_value NUMERIC(10,2) DEFAULT 0,
153 min_booking_amount NUMERIC(10,2) DEFAULT 0,
154 max_uses INTEGER,
155 uses_count INTEGER DEFAULT 0,
156 expiry_date TEXT,
157 active BOOLEAN DEFAULT TRUE,
158 description TEXT,
159 created_at TEXT
160 );
161
162 CREATE TABLE IF NOT EXISTS seo_pages (
163 page_slug TEXT PRIMARY KEY,
164 page_title TEXT,
165 meta_description TEXT,
166 meta_keywords TEXT,
167 hero_heading TEXT,
168 hero_subheading TEXT,
169 cta_text TEXT,
170 created_at TEXT,
171 updated_at TEXT
172 );
173
174 CREATE TABLE IF NOT EXISTS google_calendar_tokens (
175 type TEXT PRIMARY KEY,
176 access_token TEXT,
177 refresh_token TEXT,
178 token_type TEXT,
179 expires_in INTEGER,
180 scope TEXT,
181 updated_at TEXT
182 );
183 """)
184 logger.info("Database schema initialized")
185
186
187async def close_pool():
188 """Close the connection pool (call on shutdown)."""
189 global _pool
190 if _pool:
191 await _pool.close()
192 _pool = None
Deletedbackend/render_redeploy_ping.txt+0−1View fileUnifiedSplit
1redeploy ping 2026-01-27T09:47:32
Deletedbackend/requirements.txt+0−53View fileUnifiedSplit
1annotated-types==0.7.0
2anyio==4.11.0
3APScheduler==3.11.2
4asyncpg==0.30.0
5bcrypt==4.1.3
6cachetools==6.2.2
7certifi==2025.11.12
8cffi==2.0.0
9charset-normalizer==3.4.4
10click==8.3.1
11cryptography==46.0.3
12ecdsa==0.19.1
13email-validator==2.3.0
14fastapi==0.110.1
15google-api-python-client==2.187.0
16google-auth==2.41.1
17google-auth-httplib2==0.2.1
18google-auth-oauthlib==1.2.3
19googleapis-common-protos==1.72.0
20h11==0.16.0
21httpcore==1.0.9
22httplib2==0.31.0
23httpx==0.28.1
24icalendar==6.3.2
25idna==3.11
26passlib==1.7.4
27pyasn1==0.6.1
28pyasn1_modules==0.4.2
29pycparser==2.23
30pydantic==2.12.4
31pydantic_core==2.41.5
32PyJWT==2.10.1
33python-dateutil==2.9.0.post0
34python-dotenv==1.2.1
35python-jose==3.5.0
36python-multipart==0.0.20
37pytz==2025.2
38requests==2.32.5
39requests-oauthlib==2.0.0
40rsa==4.9.1
41six==1.17.0
42sniffio==1.3.1
43starlette==0.37.2
44stripe==14.0.1
45twilio==9.8.8
46typing-inspection==0.4.2
47typing_extensions==4.15.0
48tzdata==2025.2
49tzlocal==5.3.1
50uritemplate==4.2.0
51urllib3==2.5.0
52uvicorn==0.25.0
53vobject==0.9.9
Deletedbackend/runtime.txt+0−1View fileUnifiedSplit
1python-3.11.9
Deletedbackend/server.py+0−262View fileUnifiedSplit
1# backend/server.py
2# Main FastAPI application entry point for Hibiscus to Airport
3# Deployed on Render via Docker: uvicorn backend.server:app
4
5import os
6import sys
7import logging
8from datetime import datetime, timedelta, timezone
9from pathlib import Path
10
11# ---------------------------------------------------------------------------
12# sys.path: ensure the backend directory is on the path so that
13# booking_routes.py, auth.py, utils.py etc. can use bare imports
14# (e.g. "from auth import ...") as well as package-qualified imports.
15# ---------------------------------------------------------------------------
16HERE = os.path.dirname(os.path.abspath(__file__)) # .../backend
17ROOT = os.path.dirname(HERE) # repo root
18
19for p in (HERE, ROOT):
20 if p and p not in sys.path:
21 sys.path.insert(0, p)
22
23from dotenv import load_dotenv
24load_dotenv(Path(HERE) / '.env')
25
26from fastapi import FastAPI, Request
27from fastapi.responses import JSONResponse
28from starlette.middleware.cors import CORSMiddleware
29from starlette.middleware.base import BaseHTTPMiddleware
30
31# ---------------------------------------------------------------------------
32# Create the app
33# ---------------------------------------------------------------------------
34BUILD_STAMP = "SYSTEM_ACCESS_BOOKINGS_FIX_20260215"
35
36app = FastAPI(title="Hibiscus to Airport Booking API")
37
38# ---------------------------------------------------------------------------
39# Logging
40# ---------------------------------------------------------------------------
41logging.basicConfig(
42 level=logging.INFO,
43 format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
44)
45logger = logging.getLogger(__name__)
46
47# ---------------------------------------------------------------------------
48# Middleware: prevent Cloudflare/CDN caching of API responses
49# ---------------------------------------------------------------------------
50class NoCacheMiddleware(BaseHTTPMiddleware):
51 async def dispatch(self, request: Request, call_next):
52 response = await call_next(request)
53 if request.url.path.startswith("/api"):
54 response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0, private"
55 response.headers["Pragma"] = "no-cache"
56 response.headers["Expires"] = "0"
57 response.headers["Surrogate-Control"] = "no-store"
58 response.headers["CDN-Cache-Control"] = "no-store"
59 response.headers["Cloudflare-CDN-Cache-Control"] = "no-store"
60 return response
61
62app.add_middleware(NoCacheMiddleware)
63
64# CORS — allow the known frontend origins (set CORS_ORIGINS env var for custom list)
65_CORS_ORIGINS = os.environ.get(
66 "CORS_ORIGINS",
67 "https://hibiscustoairport.co.nz,https://www.hibiscustoairport.co.nz,http://localhost:3000"
68).split(",")
69app.add_middleware(
70 CORSMiddleware,
71 allow_credentials=True,
72 allow_origins=[o.strip() for o in _CORS_ORIGINS],
73 allow_methods=["*"],
74 allow_headers=["*"],
75)
76
77# ---------------------------------------------------------------------------
78# Core diagnostic endpoints (always available, no dependencies)
79# ---------------------------------------------------------------------------
80def _utc() -> str:
81 return datetime.utcnow().isoformat() + "Z"
82
83@app.get("/debug/stamp")
84def debug_stamp():
85 return {"stamp": BUILD_STAMP, "utc": _utc()}
86
87@app.get("/debug/which")
88def debug_which():
89 return {"module": "backend.server", "stamp": BUILD_STAMP, "utc": _utc()}
90
91@app.get("/healthz")
92def healthz():
93 return {"ok": True, "stamp": BUILD_STAMP, "utc": _utc()}
94
95@app.get("/debug/routes")
96def debug_routes():
97 out = []
98 for r in app.routes:
99 p = getattr(r, "path", "")
100 methods = sorted(list(getattr(r, "methods", []) or []))
101 if p:
102 out.append({"path": p, "methods": methods})
103 return {"count": len(out), "routes": out}
104
105# ---------------------------------------------------------------------------
106# Import and include routers (with resilience — each router is optional)
107# ---------------------------------------------------------------------------
108
109# 1) Booking routes under /api prefix (the React frontend expects /api/...)
110try:
111 from booking_routes import router as booking_router
112 from fastapi import APIRouter
113 api_router = APIRouter(prefix="/api")
114 api_router.include_router(booking_router, tags=["bookings"])
115 app.include_router(api_router)
116 logger.info("booking_routes mounted under /api")
117except Exception as e:
118 logger.error(f"FAILED to import booking_routes: {e}")
119
120# 2) Admin routes (HTML admin panel: /admin, /admin/login, /admin/logout, /admin/status)
121try:
122 from admin_routes import router as admin_router
123 app.include_router(admin_router, tags=["admin"])
124 logger.info("admin_routes mounted")
125except Exception as e:
126 logger.error(f"FAILED to import admin_routes: {e}")
127
128# 3) Cockpit routes (/admin/cockpit)
129try:
130 from cockpit_routes import cockpit_router
131 app.include_router(cockpit_router, tags=["cockpit"])
132 logger.info("cockpit_routes mounted")
133except Exception as e:
134 logger.error(f"FAILED to import cockpit_routes: {e}")
135
136# 4) Booking form editor routes (/admin/booking-form, /api/public/booking-form, /api/admin/booking-form)
137try:
138 from bookingform_routes import router as bookingform_router
139 app.include_router(bookingform_router, tags=["bookingform"])
140 logger.info("bookingform_routes mounted")
141except Exception as e:
142 logger.error(f"FAILED to import bookingform_routes: {e}")
143
144# 5) Agent routes (cockpit automation: /api/cockpit/state, /api/cockpit/run, /agent-cockpit)
145try:
146 from agent_routes import router as agent_router
147 app.include_router(agent_router, tags=["agents"])
148 logger.info("agent_routes mounted")
149except Exception as e:
150 logger.error(f"FAILED to import agent_routes: {e}")
151
152# ---------------------------------------------------------------------------
153# Fallback /api root
154# ---------------------------------------------------------------------------
155@app.get("/api/")
156async def api_root():
157 return {"message": "Hibiscus to Airport Booking API", "status": "running", "stamp": BUILD_STAMP}
158
159@app.get("/api/agents/ping")
160def agents_ping():
161 return {"ok": True, "stamp": BUILD_STAMP, "utc": _utc()}
162
163# ---------------------------------------------------------------------------
164# Scheduler: day-before reminder emails & SMS
165# ---------------------------------------------------------------------------
166try:
167 from apscheduler.schedulers.asyncio import AsyncIOScheduler
168 from apscheduler.triggers.cron import CronTrigger
169 from db import get_pool, close_pool
170
171 scheduler = AsyncIOScheduler()
172
173 async def send_day_before_reminders():
174 """Send reminders for bookings happening tomorrow — runs daily at 6 PM NZ time."""
175 try:
176 logger.info("Running day-before reminder job...")
177 pool = await get_pool()
178 tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime('%Y-%m-%d')
179 rows = await pool.fetch(
180 """SELECT * FROM bookings
181 WHERE date = $1 AND status = 'confirmed'
182 AND payment_status = 'paid'
183 AND (reminder_sent IS NULL OR reminder_sent = FALSE)
184 LIMIT 100""",
185 tomorrow
186 )
187 logger.info(f"Found {len(rows)} bookings for tomorrow ({tomorrow}) needing reminders")
188
189 try:
190 from utils import send_email, send_sms, format_date_nz
191 except ImportError:
192 logger.error("Could not import email/sms utils for reminders")
193 return
194
195 sent_count = 0
196 for row in rows:
197 booking = dict(row)
198 try:
199 booking_ref = booking.get('booking_ref', 'N/A')
200 formatted_date = format_date_nz(booking['date'])
201 subject = f"Reminder: Your Airport Transfer Tomorrow - {booking_ref}"
202 body = f"""
203 <div style="max-width:600px;margin:0 auto;font-family:Arial,sans-serif;">
204 <div style="background:linear-gradient(135deg,#1f2937,#111827);color:#fff;padding:30px;border-radius:10px 10px 0 0;">
205 <h1 style="margin:0;font-size:24px;">Transfer Reminder</h1>
206 <p style="margin:8px 0 0;color:#f59e0b;">Your transfer is tomorrow!</p>
207 </div>
208 <div style="background:#fff;padding:30px;border-radius:0 0 10px 10px;border:1px solid #e5e7eb;">
209 <p>Hi {booking['name']},</p>
210 <p>Just a friendly reminder that your airport transfer is scheduled for <strong>tomorrow</strong>.</p>
211 <div style="background:#f8fafc;padding:20px;border-radius:8px;margin:20px 0;border-left:4px solid #f59e0b;">
212 <p><strong>Booking:</strong> {booking_ref}</p>
213 <p><strong>Date &amp; Time:</strong> {formatted_date} at {booking['time']}</p>
214 <p><strong>Pickup:</strong> {booking['pickup_address']}</p>
215 <p><strong>Drop-off:</strong> {booking['dropoff_address']}</p>
216 </div>
217 <p>Questions? Email us at bookings@bookaride.co.nz</p>
218 </div>
219 </div>"""
220 send_email(booking['email'], subject, body)
221
222 sms_message = (
223 f"REMINDER: Your airport transfer is tomorrow!\n"
224 f"Ref: {booking_ref}\n"
225 f"Pickup: {formatted_date} at {booking['time']}\n"
226 f"From: {(booking['pickup_address'] or '')[:50]}\n"
227 f"Be ready 5-10 mins early. Questions? info@bookaride.co.nz"
228 )
229 send_sms(booking['phone'], sms_message)
230
231 await pool.execute(
232 "UPDATE bookings SET reminder_sent = TRUE, reminder_sent_at = $1 WHERE id = $2",
233 datetime.now(timezone.utc).isoformat(), booking['id']
234 )
235 sent_count += 1
236 logger.info(f"Reminder sent for booking {booking_ref}")
237 except Exception as e:
238 logger.error(f"Failed to send reminder for booking {booking.get('booking_ref')}: {e}")
239
240 logger.info(f"Day-before reminders complete: {sent_count} sent")
241 except Exception as e:
242 logger.error(f"Error in day-before reminder job: {e}")
243
244 @app.on_event("startup")
245 async def start_scheduler():
246 scheduler.add_job(
247 send_day_before_reminders,
248 CronTrigger(hour=5, minute=0), # 5 AM UTC = 6 PM NZDT
249 id="day_before_reminders",
250 replace_existing=True
251 )
252 scheduler.start()
253 logger.info("Scheduler started - Day-before reminders will run at 6 PM NZ time daily")
254
255 @app.on_event("shutdown")
256 async def shutdown_scheduler():
257 scheduler.shutdown()
258 await close_pool()
259 logger.info("Scheduler and DB pool shutdown")
260
261except Exception as e:
262 logger.warning(f"Scheduler not available: {e}")
Deletedbackend/tests/test_cancel_booking.py+0−257View fileUnifiedSplit
1"""
2Test suite for Cancel Booking functionality
3Tests the DELETE /api/bookings/{booking_id} endpoint which:
41. Sends cancellation SMS to customer
52. Sends cancellation email to customer
63. Soft-deletes booking (moves to deleted_bookings collection)
7"""
8
9import pytest
10import requests
11import os
12import time
13
14BASE_URL = os.environ.get('REACT_APP_BACKEND_URL', '').rstrip('/')
15
16class TestCancelBooking:
17 """Test Cancel Booking functionality"""
18
19 @pytest.fixture(autouse=True)
20 def setup(self):
21 """Setup - get auth token"""
22 self.token = None
23 response = requests.post(
24 f"{BASE_URL}/api/admin/login",
25 json={"username": "admin", "password": "Kongkong2025!@"},
26 headers={"Content-Type": "application/json"}
27 )
28 if response.status_code == 200:
29 self.token = response.json().get("access_token")
30 yield
31
32 def get_headers(self):
33 """Get headers with auth token"""
34 return {
35 "Authorization": f"Bearer {self.token}",
36 "Content-Type": "application/json"
37 }
38
39 def test_admin_login(self):
40 """Test admin login works"""
41 response = requests.post(
42 f"{BASE_URL}/api/admin/login",
43 json={"username": "admin", "password": "Kongkong2025!@"},
44 headers={"Content-Type": "application/json"}
45 )
46 assert response.status_code == 200
47 data = response.json()
48 assert "access_token" in data
49 assert data["token_type"] == "bearer"
50
51 def test_get_bookings(self):
52 """Test fetching bookings list"""
53 if not self.token:
54 pytest.skip("Auth failed")
55
56 response = requests.get(
57 f"{BASE_URL}/api/bookings",
58 headers=self.get_headers()
59 )
60 assert response.status_code == 200
61 data = response.json()
62 assert isinstance(data, list)
63
64 def test_create_and_cancel_booking(self):
65 """Test creating a booking and then cancelling it"""
66 if not self.token:
67 pytest.skip("Auth failed")
68
69 # Create a test booking
70 booking_data = {
71 "name": "TEST_Cancel_Pytest",
72 "email": "pytest_cancel@example.com",
73 "phone": "+64211234888",
74 "pickupAddress": "Test Pickup Address, Auckland",
75 "dropoffAddress": "Auckland Airport",
76 "date": "2026-02-15",
77 "time": "11:00",
78 "passengers": "1",
79 "notes": "Pytest test booking for cancellation",
80 "pricing": {
81 "distance": 30,
82 "basePrice": 150,
83 "airportFee": 0,
84 "passengerFee": 0,
85 "oversizedLuggageFee": 0,
86 "totalPrice": 150,
87 "ratePerKm": 5
88 }
89 }
90
91 create_response = requests.post(
92 f"{BASE_URL}/api/bookings",
93 json=booking_data,
94 headers=self.get_headers()
95 )
96 assert create_response.status_code == 200
97 created = create_response.json()
98 assert "booking_id" in created
99 assert "booking_ref" in created
100
101 booking_id = created["booking_id"]
102 booking_ref = created["booking_ref"]
103
104 # Cancel the booking
105 cancel_response = requests.delete(
106 f"{BASE_URL}/api/bookings/{booking_id}",
107 headers=self.get_headers()
108 )
109 assert cancel_response.status_code == 200
110 cancel_data = cancel_response.json()
111 assert cancel_data["message"] == "Booking cancelled and moved to deleted (can be restored)"
112 assert cancel_data["booking_ref"] == booking_ref
113
114 # Verify booking is no longer in active bookings
115 bookings_response = requests.get(
116 f"{BASE_URL}/api/bookings",
117 headers=self.get_headers()
118 )
119 assert bookings_response.status_code == 200
120 active_bookings = bookings_response.json()
121 active_ids = [b["id"] for b in active_bookings]
122 assert booking_id not in active_ids
123
124 # Verify booking is in deleted bookings
125 deleted_response = requests.get(
126 f"{BASE_URL}/api/bookings/deleted/list",
127 headers=self.get_headers()
128 )
129 assert deleted_response.status_code == 200
130 deleted_bookings = deleted_response.json()
131 deleted_ids = [b["id"] for b in deleted_bookings]
132 assert booking_id in deleted_ids
133
134 # Verify deleted booking has deletedAt and deletedBy fields
135 deleted_booking = next((b for b in deleted_bookings if b["id"] == booking_id), None)
136 assert deleted_booking is not None
137 assert "deletedAt" in deleted_booking
138 assert deleted_booking["deletedBy"] == "admin"
139
140 def test_cancel_nonexistent_booking(self):
141 """Test cancelling a booking that doesn't exist"""
142 if not self.token:
143 pytest.skip("Auth failed")
144
145 response = requests.delete(
146 f"{BASE_URL}/api/bookings/nonexistent-booking-id-12345",
147 headers=self.get_headers()
148 )
149 assert response.status_code == 404
150 data = response.json()
151 assert "detail" in data
152 assert "not found" in data["detail"].lower()
153
154 def test_cancel_without_auth(self):
155 """Test cancelling without authentication"""
156 response = requests.delete(
157 f"{BASE_URL}/api/bookings/some-booking-id",
158 headers={"Content-Type": "application/json"}
159 )
160 # API returns 403 Forbidden for missing auth
161 assert response.status_code in [401, 403]
162
163 def test_get_deleted_bookings(self):
164 """Test fetching deleted bookings list"""
165 if not self.token:
166 pytest.skip("Auth failed")
167
168 response = requests.get(
169 f"{BASE_URL}/api/bookings/deleted/list",
170 headers=self.get_headers()
171 )
172 assert response.status_code == 200
173 data = response.json()
174 assert isinstance(data, list)
175
176 # Verify structure of deleted bookings
177 if len(data) > 0:
178 booking = data[0]
179 assert "id" in booking
180 assert "booking_ref" in booking
181 assert "deletedAt" in booking
182 assert "deletedBy" in booking
183
184
185class TestCancellationNotifications:
186 """Test that cancellation sends proper notifications"""
187
188 @pytest.fixture(autouse=True)
189 def setup(self):
190 """Setup - get auth token"""
191 self.token = None
192 response = requests.post(
193 f"{BASE_URL}/api/admin/login",
194 json={"username": "admin", "password": "Kongkong2025!@"},
195 headers={"Content-Type": "application/json"}
196 )
197 if response.status_code == 200:
198 self.token = response.json().get("access_token")
199 yield
200
201 def get_headers(self):
202 return {
203 "Authorization": f"Bearer {self.token}",
204 "Content-Type": "application/json"
205 }
206
207 def test_cancellation_sends_notifications(self):
208 """
209 Test that cancellation triggers SMS and email notifications.
210 Note: We can't directly verify SMS/email delivery, but we verify
211 the endpoint returns success which indicates notifications were attempted.
212 """
213 if not self.token:
214 pytest.skip("Auth failed")
215
216 # Create a booking
217 booking_data = {
218 "name": "TEST_Notification_Test",
219 "email": "notification_test@example.com",
220 "phone": "+64211234777",
221 "pickupAddress": "Notification Test Address",
222 "dropoffAddress": "Auckland Airport",
223 "date": "2026-03-01",
224 "time": "09:00",
225 "passengers": "1",
226 "notes": "Testing notification on cancel",
227 "pricing": {
228 "distance": 20,
229 "basePrice": 100,
230 "airportFee": 0,
231 "passengerFee": 0,
232 "oversizedLuggageFee": 0,
233 "totalPrice": 100,
234 "ratePerKm": 5
235 }
236 }
237
238 create_response = requests.post(
239 f"{BASE_URL}/api/bookings",
240 json=booking_data,
241 headers=self.get_headers()
242 )
243 assert create_response.status_code == 200
244 booking_id = create_response.json()["booking_id"]
245
246 # Cancel and verify success (notifications are sent in the backend)
247 cancel_response = requests.delete(
248 f"{BASE_URL}/api/bookings/{booking_id}",
249 headers=self.get_headers()
250 )
251 assert cancel_response.status_code == 200
252 # Success response indicates notifications were attempted
253 # Backend logs will show actual SMS/email delivery status
254
255
256if __name__ == "__main__":
257 pytest.main([__file__, "-v", "--tb=short"])
Deletedbackend/utils.py+0−924View fileUnifiedSplit
1import os
2import requests
3from twilio.rest import Client
4import logging
5from datetime import datetime
6import vobject
7import uuid
8from db import get_pool
9
10# iCloud/CardDAV Configuration
11ICLOUD_USERNAME = os.environ.get('ICLOUD_USERNAME', '')
12ICLOUD_APP_PASSWORD = os.environ.get('ICLOUD_APP_PASSWORD', '')
13CARDDAV_URL = "https://contacts.icloud.com"
14
15
16# Booking Reference Generator
17async def generate_booking_reference():
18 """Generate booking reference starting from H1, H2, H3..."""
19 try:
20 pool = await get_pool()
21 row = await pool.fetchrow(
22 "SELECT booking_ref FROM bookings WHERE booking_ref LIKE 'H%' ORDER BY booking_ref DESC LIMIT 1"
23 )
24
25 if row and row['booking_ref']:
26 # Extract number from H123 format
27 last_num = int(row['booking_ref'][1:])
28 next_num = last_num + 1
29 else:
30 next_num = 1
31
32 return f"H{next_num}"
33 except Exception as e:
34 logger.error(f"Error generating booking reference: {str(e)}")
35 return "H1"
36
37# iCloud Contact Sync via Email (more reliable than CardDAV)
38def sync_contact_to_icloud(booking: dict):
39 """Sync customer contact to iCloud/iPhone by emailing vCard to iCloud email"""
40 try:
41 # Create vCard
42 vcard = vobject.vCard()
43
44 # Parse name
45 name_parts = booking.get('name', 'Customer').split(' ', 1)
46 first_name = name_parts[0]
47 last_name = name_parts[1] if len(name_parts) > 1 else ''
48
49 # Add name
50 vcard.add('n')
51 vcard.n.value = vobject.vcard.Name(family=last_name, given=first_name)
52 vcard.add('fn')
53 vcard.fn.value = booking.get('name', 'Customer')
54
55 # Add phone
56 if booking.get('phone'):
57 tel = vcard.add('tel')
58 tel.value = booking.get('phone')
59 tel.type_param = 'CELL'
60
61 # Add email
62 if booking.get('email'):
63 email = vcard.add('email')
64 email.value = booking.get('email')
65 email.type_param = 'INTERNET'
66
67 # Add booking info as note
68 booking_ref = booking.get('booking_ref', 'N/A')
69 booking_date = booking.get('date', 'N/A')
70 note = f"Hibiscus to Airport Customer\nBooking: {booking_ref}\nDate: {booking_date}\nPickup: {booking.get('pickupAddress', 'N/A')}\nDropoff: {booking.get('dropoffAddress', 'N/A')}"
71
72 vcard.add('note')
73 vcard.note.value = note
74
75 # Add organization
76 vcard.add('org')
77 vcard.org.value = ['Hibiscus to Airport - Customer']
78
79 # Generate unique ID
80 contact_uid = str(uuid.uuid4())
81 vcard.add('uid')
82 vcard.uid.value = contact_uid
83
84 # Serialize to vCard format
85 vcard_data = vcard.serialize()
86
87 # Method 1: Try CardDAV first
88 if ICLOUD_USERNAME and ICLOUD_APP_PASSWORD:
89 try:
90 carddav_endpoint = f"{CARDDAV_URL}/{ICLOUD_USERNAME}/carddavhome/card/{contact_uid}.vcf"
91
92 response = requests.put(
93 carddav_endpoint,
94 auth=(ICLOUD_USERNAME, ICLOUD_APP_PASSWORD),
95 headers={
96 'Content-Type': 'text/vcard; charset=utf-8',
97 },
98 data=vcard_data,
99 timeout=30
100 )
101
102 if response.status_code in [200, 201, 204]:
103 logger.info(f"Contact synced to iCloud via CardDAV: {booking.get('name')} ({booking_ref})")
104 return True
105 except Exception as e:
106 logger.warning(f"CardDAV sync failed, trying email method: {str(e)}")
107
108 # Method 2: Email vCard to iCloud email (opens as contact on iPhone)
109 # Send vCard as email attachment to iCloud email
110 icloud_email = f"{ICLOUD_USERNAME}@icloud.com" if ICLOUD_USERNAME else None
111
112 if icloud_email:
113 try:
114 # Send vCard via Mailgun with attachment
115 api_key = os.environ.get('MAILGUN_API_KEY')
116 domain = os.environ.get('MAILGUN_DOMAIN')
117 if not api_key or not domain:
118 logger.warning("Mailgun not configured, skipping iCloud vCard sync")
119 return False
120
121 subject = f"New Customer Contact - {booking.get('name')} ({booking_ref})"
122 text_body = f"New booking customer:\n\nName: {booking.get('name')}\nPhone: {booking.get('phone')}\nEmail: {booking.get('email')}\nBooking: {booking_ref}\n\nOpen the attached vCard to add to contacts."
123
124 response = requests.post(
125 f"https://api.mailgun.net/v3/{domain}/messages",
126 auth=("api", api_key),
127 files=[("attachment", (f"{booking.get('name', 'contact')}.vcf", vcard_data, "text/vcard"))],
128 data={
129 "from": os.environ.get('SENDER_EMAIL', 'noreply@bookaride.co.nz'),
130 "to": icloud_email,
131 "subject": subject,
132 "text": text_body,
133 }
134 )
135
136 if response.status_code == 200:
137 logger.info(f"Contact vCard emailed to iCloud: {booking.get('name')} ({booking_ref})")
138 return True
139 else:
140 logger.error(f"Mailgun vCard send failed ({response.status_code}): {response.text}")
141 return False
142
143 except Exception as email_error:
144 logger.error(f"Failed to email vCard to iCloud: {str(email_error)}")
145 return False
146
147 return False
148
149 except Exception as e:
150 logger.error(f"Error syncing contact to iCloud: {str(e)}")
151 return False
152
153# Date Formatter
154def format_date_nz(date_str):
155 """Format date as DD/MM/YYYY (NZ format)"""
156 try:
157 if isinstance(date_str, str):
158 date_obj = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
159 else:
160 date_obj = date_str
161 return date_obj.strftime('%d/%m/%Y')
162 except Exception as e:
163 logger.error(f"Error formatting date: {str(e)}")
164 return date_str
165
166def format_date_with_day(date_str):
167 """Format date as DD/MM/YYYY (DayName) - e.g., 27/12/2025 (Saturday)"""
168 try:
169 if isinstance(date_str, str):
170 date_obj = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
171 else:
172 date_obj = date_str
173 day_name = date_obj.strftime('%A') # Full day name (Saturday, Sunday, etc.)
174 date_formatted = date_obj.strftime('%d/%m/%Y')
175 return f"{date_formatted} ({day_name})"
176 except Exception as e:
177 logger.error(f"Error formatting date with day: {str(e)}")
178 return date_str
179
180# Generate Calendar Invite
181def generate_calendar_invite(booking: dict):
182 """Generate iCal calendar invite for booking"""
183 try:
184 cal = Calendar()
185 cal.add('prodid', '-//Hibiscus to Airport//Booking System//EN')
186 cal.add('version', '2.0')
187
188 event = Event()
189 event.add('summary', f"Airport Transfer - {booking['name']}")
190 event.add('description',
191 f"Booking Ref: {booking.get('booking_ref', 'N/A')}\n"
192 f"Customer: {booking['name']}\n"
193 f"Phone: {booking['phone']}\n"
194 f"Pickup: {booking['pickupAddress']}\n"
195 f"Dropoff: {booking['dropoffAddress']}\n"
196 f"Passengers: {booking.get('passengers', 1)}\n"
197 f"Notes: {booking.get('notes', 'N/A')}"
198 )
199
200 # Parse date and time
201 booking_datetime = datetime.strptime(
202 f"{booking['date']} {booking['time']}",
203 "%Y-%m-%d %H:%M"
204 )
205 nz_tz = timezone('Pacific/Auckland')
206 booking_datetime = nz_tz.localize(booking_datetime)
207
208 event.add('dtstart', booking_datetime)
209 event.add('dtend', booking_datetime) # Same time, can adjust duration if needed
210 event.add('location', booking['pickupAddress'])
211
212 cal.add_component(event)
213
214 return cal.to_ical()
215 except Exception as e:
216 logger.error(f"Error generating calendar invite: {str(e)}")
217 return None
218
219from icalendar import Calendar, Event
220from pytz import timezone
221
222logger = logging.getLogger(__name__)
223
224# Google Maps Distance Calculator
225def calculate_distance(pickup: str, dropoff: str):
226 """Calculate distance between two addresses using Google Distance Matrix API"""
227 try:
228 api_key = os.environ.get('GOOGLE_MAPS_API_KEY')
229 url = f"https://maps.googleapis.com/maps/api/distancematrix/json"
230 params = {
231 'origins': pickup,
232 'destinations': dropoff,
233 'key': api_key,
234 'units': 'metric'
235 }
236
237 response = requests.get(url, params=params)
238 data = response.json()
239
240 if data['status'] == 'OK' and data['rows'][0]['elements'][0]['status'] == 'OK':
241 distance_meters = data['rows'][0]['elements'][0]['distance']['value']
242 distance_km = distance_meters / 1000
243 return round(distance_km, 2)
244 else:
245 logger.error(f"Google Maps API error: {data}")
246 return None
247 except Exception as e:
248 logger.error(f"Error calculating distance: {str(e)}")
249 return None
250
251# Tiered Pricing Engine - Updated to match BookaRide exactly
252def calculate_price(distance_km: float, passengers: int = 1, vip_pickup: bool = False, oversized_luggage: bool = False):
253 """
254 Calculate price based on distance bracket and passengers.
255
256 IMPORTANT: The rate is based on TOTAL distance, not incremental.
257 A 30km trip uses $5.00/km for the ENTIRE distance.
258
259 Pricing tiers:
260 - 0 - 15 km: $12.00/km
261 - 15 - 15.8 km: $8.00/km
262 - 15.8 - 16 km: $6.00/km
263 - 16 - 25.5 km: $5.50/km
264 - 25.5 - 35 km: $5.00/km
265 - 35 - 50 km: $4.00/km
266 - 50 - 60 km: $2.60/km
267 - 60 - 75 km: $2.47/km
268 - 75 - 100 km: $2.70/km
269 - 100+ km: $3.50/km
270 """
271
272 # Determine rate based on TOTAL distance bracket (not incremental)
273 if distance_km <= 15.0:
274 rate_per_km = 12.00
275 elif distance_km <= 15.8:
276 rate_per_km = 8.00
277 elif distance_km <= 16.0:
278 rate_per_km = 6.00
279 elif distance_km <= 25.5:
280 rate_per_km = 5.50
281 elif distance_km <= 35.0:
282 rate_per_km = 5.00
283 elif distance_km <= 50.0:
284 rate_per_km = 4.00
285 elif distance_km <= 60.0:
286 rate_per_km = 2.60
287 elif distance_km <= 75.0:
288 rate_per_km = 2.47
289 elif distance_km <= 100.0:
290 rate_per_km = 2.70
291 else:
292 rate_per_km = 3.50
293
294 # Calculate base price (total distance × rate)
295 base_price = distance_km * rate_per_km
296
297 # Additional fees
298 passenger_fee = max(0, passengers - 1) * 5.00 # $5 per extra passenger
299 airport_fee = 15.00 if vip_pickup else 0.00 # VIP airport pickup
300 luggage_fee = 25.00 if oversized_luggage else 0.00 # Oversized luggage
301
302 # Calculate total
303 total_price = base_price + passenger_fee + airport_fee + luggage_fee
304
305 # Apply minimum fare of $100
306 if total_price < 100.0:
307 total_price = 100.0
308 base_price = 100.0 - passenger_fee - airport_fee - luggage_fee
309
310 return {
311 'distance': round(distance_km, 2),
312 'basePrice': round(base_price, 2),
313 'airportFee': round(airport_fee, 2),
314 'passengerFee': round(passenger_fee, 2),
315 'oversizedLuggageFee': round(luggage_fee, 2),
316 'totalPrice': round(total_price, 2),
317 'ratePerKm': rate_per_km
318 }
319
320# Email Notifications — via Mailgun HTTP API
321#
322# Requires:
323# MAILGUN_API_KEY – Mailgun API key
324# MAILGUN_DOMAIN – Mailgun sending domain
325# SENDER_EMAIL – the "from" address (default: noreply@bookaride.co.nz)
326
327def send_email(to_email: str, subject: str, body: str, calendar_invite=None):
328 """Send email via Mailgun HTTP API."""
329 try:
330 api_key = os.environ.get('MAILGUN_API_KEY')
331 domain = os.environ.get('MAILGUN_DOMAIN')
332 sender_email = os.environ.get('SENDER_EMAIL', 'noreply@bookaride.co.nz')
333
334 if not api_key or not domain:
335 logger.error("Mailgun not configured (set MAILGUN_API_KEY and MAILGUN_DOMAIN)")
336 return False
337
338 html_body = f"""
339 <html>
340 <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
341 {body}
342 </body>
343 </html>
344 """
345
346 data = {
347 "from": sender_email,
348 "to": [to_email],
349 "subject": subject,
350 "html": html_body,
351 }
352
353 files = []
354 if calendar_invite:
355 invite_bytes = calendar_invite if isinstance(calendar_invite, bytes) else calendar_invite.encode('utf-8')
356 files.append(("attachment", ("booking.ics", invite_bytes, "text/calendar")))
357
358 response = requests.post(
359 f"https://api.mailgun.net/v3/{domain}/messages",
360 auth=("api", api_key),
361 data=data,
362 files=files if files else None,
363 )
364
365 if response.status_code == 200:
366 logger.info(f"Email sent via Mailgun to {to_email}")
367 return True
368 else:
369 logger.error(f"Mailgun API error ({response.status_code}): {response.text}")
370 return False
371 except Exception as e:
372 logger.error(f"Error sending email: {str(e)}")
373 return False
374
375# SMS Notifications
376def send_sms(to_phone: str, message: str):
377 """Send SMS via Twilio"""
378 try:
379 account_sid = os.environ.get('TWILIO_ACCOUNT_SID')
380 auth_token = os.environ.get('TWILIO_AUTH_TOKEN')
381 from_phone = os.environ.get('TWILIO_PHONE_NUMBER')
382
383 client = Client(account_sid, auth_token)
384
385 message = client.messages.create(
386 body=message,
387 from_=from_phone,
388 to=to_phone
389 )
390
391 logger.info(f"SMS sent successfully to {to_phone}")
392 return True
393 except Exception as e:
394 logger.error(f"Error sending SMS: {str(e)}")
395 return False
396
397# Customer Confirmation Email Template
398def send_customer_confirmation(booking: dict):
399 """Send premium booking confirmation email to customer"""
400 booking_ref = booking.get('booking_ref', 'N/A')
401 formatted_date = format_date_with_day(booking['date'])
402
403 subject = f"✈️ Your Premium Transfer is Confirmed - {booking_ref}"
404
405 # Generate calendar invite
406 calendar_invite = generate_calendar_invite(booking)
407
408 body = f"""
409 <div style="max-width: 650px; margin: 0 auto; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f8fafc;">
410 <!-- Header -->
411 <div style="background: linear-gradient(135deg, #1f2937 0%, #111827 100%); color: white; padding: 40px 30px; text-align: center;">
412 <h1 style="margin: 0; font-size: 24px; font-weight: 300; letter-spacing: 2px; text-transform: uppercase;">
413 🏆 HIBISCUS TO AIRPORT
414 </h1>
415 <p style="margin: 8px 0 0; font-size: 14px; color: #f59e0b; font-weight: 500; letter-spacing: 1px;">
416 PREMIUM TRANSPORTATION
417 </p>
418 </div>
419
420 <!-- Main Content -->
421 <div style="background: white; padding: 40px 30px;">
422 <div style="text-align: center; margin-bottom: 30px;">
423 <h2 style="margin: 0; color: #1f2937; font-size: 28px; font-weight: 600;">
424 Dear {booking['name']},
425 </h2>
426 <p style="margin: 15px 0 0; color: #6b7280; font-size: 16px; line-height: 1.6;">
427 Your premium airport transfer has been confirmed. We look forward to providing you with exceptional service.
428 </p>
429 </div>
430
431 <!-- Confirmation Box -->
432 <div style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: 25px; border-radius: 12px; text-align: center; margin: 30px 0;">
433 <h3 style="margin: 0 0 10px; font-size: 18px; font-weight: 600; letter-spacing: 1px;">
434 BOOKING CONFIRMATION
435 </h3>
436 <p style="margin: 0; font-size: 24px; font-weight: bold; letter-spacing: 2px;">
437 {booking_ref}
438 </p>
439 <p style="margin: 10px 0 0; font-size: 14px; opacity: 0.9;">
440 ✅ CONFIRMED & PAID
441 </p>
442 </div>
443
444 <!-- Transfer Details -->
445 <div style="background: #f8fafc; border-radius: 12px; padding: 30px; margin: 30px 0; border-left: 4px solid #f59e0b;">
446 <h3 style="margin: 0 0 20px; color: #f59e0b; font-size: 18px; font-weight: 600; display: flex; align-items: center;">
447 ✈️ TRANSFER DETAILS
448 </h3>
449
450 <div style="margin-bottom: 20px;">
451 <p style="margin: 0; color: #6b7280; font-size: 14px; font-weight: 500;">📍 PICKUP LOCATION</p>
452 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">{booking['pickupAddress']}</p>
453 </div>
454
455 <div style="margin-bottom: 20px;">
456 <p style="margin: 0; color: #6b7280; font-size: 14px; font-weight: 500;">🛬 DESTINATION</p>
457 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">{booking['dropoffAddress']}</p>
458 </div>
459
460 <div style="margin-bottom: 20px;">
461 <p style="margin: 0; color: #6b7280; font-size: 14px; font-weight: 500;">📅 DATE & TIME</p>
462 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">{formatted_date} at {booking['time']}</p>
463 </div>
464
465 <div style="margin-bottom: 20px;">
466 <p style="margin: 0; color: #6b7280; font-size: 14px; font-weight: 500;">👥 PASSENGERS</p>
467 <p style="margin: 5px 0 0; color: #1f2937; font-size: 16px; font-weight: 600;">{booking['passengers']} guests</p>
468 </div>
469
470 {'<div style="margin-bottom: 0;"><p style="margin: 0; color: #6b7280; font-size: 14px; font-weight: 500;">✈️ FLIGHT INFORMATION</p><p style="margin: 5px 0 0; color: #1f2937; font-size: 16px;">' + (f"Departure: {booking.get('departureFlightNumber', '')} at {booking.get('departureTime', '')}" if booking.get('departureFlightNumber') or booking.get('departureTime') else '') + (' | ' if (booking.get('departureFlightNumber') or booking.get('departureTime')) and (booking.get('arrivalFlightNumber') or booking.get('arrivalTime')) else '') + (f"Arrival: {booking.get('arrivalFlightNumber', '')} at {booking.get('arrivalTime', '')}" if booking.get('arrivalFlightNumber') or booking.get('arrivalTime') else '') + '</p></div>' if booking.get('departureFlightNumber') or booking.get('departureTime') or booking.get('arrivalFlightNumber') or booking.get('arrivalTime') else ''}
471 </div>
472
473 <!-- Investment Breakdown -->
474 <div style="background: white; border: 2px solid #f3f4f6; border-radius: 12px; padding: 30px; margin: 30px 0;">
475 <h3 style="margin: 0 0 20px; color: #f59e0b; font-size: 18px; font-weight: 600;">
476 💰 INVESTMENT BREAKDOWN
477 </h3>
478
479 <table style="width: 100%; border-collapse: collapse;">
480 <tr>
481 <td style="padding: 8px 0; color: #6b7280; border-bottom: 1px solid #f3f4f6;">Distance ({booking['pricing']['distance']} km)</td>
482 <td style="padding: 8px 0; text-align: right; color: #1f2937; font-weight: 600; border-bottom: 1px solid #f3f4f6;">${booking['pricing']['basePrice']:.2f}</td>
483 </tr>
484 <tr>
485 <td style="padding: 8px 0; color: #6b7280; border-bottom: 1px solid #f3f4f6;">Airport Service Fee</td>
486 <td style="padding: 8px 0; text-align: right; color: #1f2937; font-weight: 600; border-bottom: 1px solid #f3f4f6;">${booking['pricing']['airportFee']:.2f}</td>
487 </tr>
488 <tr>
489 <td style="padding: 8px 0; color: #6b7280; border-bottom: 2px solid #f59e0b;">Additional Passengers</td>
490 <td style="padding: 8px 0; text-align: right; color: #1f2937; font-weight: 600; border-bottom: 2px solid #f59e0b;">${booking['pricing']['passengerFee']:.2f}</td>
491 </tr>
492 <tr>
493 <td style="padding: 15px 0 0; color: #f59e0b; font-size: 18px; font-weight: bold;">TOTAL INVESTMENT</td>
494 <td style="padding: 15px 0 0; text-align: right; color: #f59e0b; font-size: 20px; font-weight: bold;">${booking['pricing']['totalPrice']:.2f} NZD</td>
495 </tr>
496 </table>
497 </div>
498
499 <!-- Service Expectations -->
500 <div style="background: #1f2937; color: white; border-radius: 12px; padding: 30px; margin: 30px 0;">
501 <h3 style="margin: 0 0 20px; color: #f59e0b; font-size: 18px; font-weight: 600;">
502 🎯 WHAT TO EXPECT
503 </h3>
504 <ul style="list-style: none; padding: 0; margin: 0;">
505 <li style="padding: 8px 0; display: flex; align-items: center;">
506 <span style="color: #f59e0b; margin-right: 10px;">•</span>
507 Professional driver in business attire
508 </li>
509 <li style="padding: 8px 0; display: flex; align-items: center;">
510 <span style="color: #f59e0b; margin-right: 10px;">•</span>
511 Late-model Toyota Hiace vehicle
512 </li>
513 <li style="padding: 8px 0; display: flex; align-items: center;">
514 <span style="color: #f59e0b; margin-right: 10px;">•</span>
515 Complimentary Wi-Fi & phone charging
516 </li>
517 <li style="padding: 8px 0; display: flex; align-items: center;">
518 <span style="color: #f59e0b; margin-right: 10px;">•</span>
519 Flight monitoring & pickup adjustments
520 </li>
521 <li style="padding: 8px 0; display: flex; align-items: center;">
522 <span style="color: #f59e0b; margin-right: 10px;">•</span>
523 Premium door-to-door service
524 </li>
525 </ul>
526 </div>
527
528 <!-- Contact Information -->
529 <div style="text-align: center; background: #f8fafc; border-radius: 12px; padding: 30px; margin: 30px 0;">
530 <h3 style="margin: 0 0 20px; color: #1f2937; font-size: 18px; font-weight: 600;">
531 📱 STAY CONNECTED
532 </h3>
533 <p style="margin: 10px 0; color: #6b7280; font-size: 16px;">
534 <strong>Email:</strong> <span style="color: #f59e0b;">bookings@bookaride.co.nz</span>
535 </p>
536 <p style="margin: 10px 0; color: #6b7280; font-size: 16px;">
537 <strong>Track:</strong> <span style="color: #f59e0b;">hibiscustoairport.co.nz/track/{booking_ref}</span>
538 </p>
539 </div>
540
541 <!-- Signature -->
542 <div style="text-align: center; margin-top: 40px; padding-top: 30px; border-top: 1px solid #e5e7eb;">
543 <p style="margin: 0; color: #6b7280; font-size: 16px;">
544 Best regards,<br>
545 <strong style="color: #f59e0b;">The Hibiscus to Airport Team</strong>
546 </p>
547 </div>
548
549 <p style="color: #9ca3af; font-size: 12px; text-align: center; margin: 30px 0 0; padding-top: 20px; border-top: 1px solid #f3f4f6;">
550 This is an automated confirmation email. Please keep this for your records.<br>
551 You received this email because you booked a transfer with Hibiscus to Airport.
552 </p>
553 </div>
554 </div>
555 """
556
557 # Send to customer
558 send_email(booking['email'], subject, body, calendar_invite)
559
560 # Also send calendar invite to admin
561 admin_cal_email = os.environ.get('ADMIN_EMAIL', 'bookings@bookaride.co.nz')
562 admin_subject = f"New Booking Calendar - {booking_ref}"
563 admin_body = f"<p>New booking received. Calendar invite attached.</p><p>Booking Reference: <strong>{booking_ref}</strong></p>"
564 send_email(admin_cal_email, admin_subject, admin_body, calendar_invite)
565
566 return True
567
568# Admin Notification Email
569def send_admin_notification(booking: dict):
570 """Send new booking notification to admin"""
571 booking_ref = booking.get('booking_ref', 'N/A')
572 formatted_date = format_date_with_day(booking['date'])
573 admin_email = os.environ.get('ADMIN_EMAIL', 'bookings@bookaride.co.nz')
574
575 # Flight information
576 departure_flight = booking.get('departureFlightNumber', '')
577 departure_time = booking.get('departureTime', '')
578 arrival_flight = booking.get('arrivalFlightNumber', '')
579 arrival_time = booking.get('arrivalTime', '')
580
581 flight_info = ""
582 if departure_flight or departure_time:
583 flight_info += f"<p><strong>✈️ Departure Flight:</strong> {departure_flight or 'N/A'} at {departure_time or 'N/A'}</p>"
584 if arrival_flight or arrival_time:
585 flight_info += f"<p><strong>🛬 Arrival Flight:</strong> {arrival_flight or 'N/A'} at {arrival_time or 'N/A'}</p>"
586
587 notes = booking.get('notes', '')
588 notes_section = f"<p><strong>Notes:</strong> {notes}</p>" if notes else ""
589
590 subject = f"🚗 New Booking - {booking_ref}"
591
592 body = f"""
593 <div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
594 <h2 style="color: #D4AF37; margin-bottom: 20px;">New Booking Received</h2>
595
596 <div style="background: #f9f9f9; padding: 20px; border-radius: 8px; border-left: 4px solid #D4AF37;">
597 <p><strong>Reference:</strong> <span style="font-size: 18px; color: #D4AF37;">{booking_ref}</span></p>
598 <hr style="border: none; border-top: 1px solid #ddd; margin: 15px 0;">
599
600 <p><strong>👤 Customer:</strong> {booking['name']}</p>
601 <p><strong>📞 Phone:</strong> {booking['phone']}</p>
602 <p><strong>✉️ Email:</strong> {booking['email']}</p>
603
604 <hr style="border: none; border-top: 1px solid #ddd; margin: 15px 0;">
605
606 <p><strong>📍 Pickup:</strong> {booking['pickupAddress']}</p>
607 <p><strong>🏁 Drop-off:</strong> {booking['dropoffAddress']}</p>
608 <p><strong>📅 Date/Time:</strong> {formatted_date} at {booking['time']}</p>
609 <p><strong>👥 Passengers:</strong> {booking['passengers']}</p>
610
611 {flight_info}
612
613 <hr style="border: none; border-top: 1px solid #ddd; margin: 15px 0;">
614
615 <p><strong>💰 Total Price:</strong> <span style="font-size: 18px; color: green;">${booking['pricing']['totalPrice']:.2f} NZD</span></p>
616 <p><strong>💳 Payment Status:</strong> {booking.get('payment_status', 'pending').upper()}</p>
617 <p><strong>📋 Booking Status:</strong> {booking.get('status', 'pending').upper()}</p>
618
619 {notes_section}
620 </div>
621
622 <p style="margin-top: 20px; color: #666;">Login to admin dashboard to manage this booking.</p>
623 </div>
624 """
625
626 return send_email(admin_email, subject, body)
627
628def send_admin_sms_notification(booking: dict):
629 """Send new booking SMS alert to admin"""
630 admin_phone = os.environ.get('ADMIN_PHONE')
631 if not admin_phone:
632 logger.warning("ADMIN_PHONE not set - skipping admin SMS")
633 return False
634
635 booking_ref = booking.get('booking_ref', 'N/A')
636 formatted_date = format_date_nz(booking['date'])
637 total = booking.get('totalPrice', booking.get('pricing', {}).get('totalPrice', 0))
638
639 message = f"""🚗 NEW BOOKING!
640
641Ref: {booking_ref}
642{booking['name']}
643{formatted_date} at {booking['time']}
644{booking['passengers']} pax | ${total:.2f}
645
646From: {booking['pickupAddress'][:40]}...
647To: {booking['dropoffAddress'][:40]}...
648
649Login to admin to manage."""
650
651 try:
652 send_sms(admin_phone, message)
653 logger.info(f"Admin SMS notification sent to {admin_phone} for booking {booking_ref}")
654 return True
655 except Exception as e:
656 logger.error(f"Failed to send admin SMS: {str(e)}")
657 return False
658
659# Customer SMS
660def send_customer_sms(booking: dict):
661 """Send premium booking confirmation SMS to customer"""
662 booking_ref = booking.get('booking_ref', 'N/A')
663 formatted_date = format_date_with_day(booking['date'])
664
665 message = f"""HIBISCUS TO AIRPORT
666Transfer CONFIRMED
667
668Ref: {booking_ref}
669{formatted_date}, {booking['time']}
670{booking['pickupAddress']} to {booking['dropoffAddress']}
671{booking['passengers']} passengers | ${booking.get('totalPrice', booking['pricing']['totalPrice']):.2f}
672
673Questions? Email info@bookaride.co.nz
674hibiscustoairport.co.nz"""
675
676 return send_sms(booking['phone'], message)
677
678
679
680# Cancellation Notifications
681def send_cancellation_email(booking: dict):
682 """Send booking cancellation email to customer"""
683 booking_ref = booking.get('booking_ref', 'N/A')
684 formatted_date = format_date_nz(booking['date'])
685
686 subject = f"Booking Cancelled - {booking_ref}"
687
688 body = f"""
689 <div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9f9f9;">
690 <div style="background: linear-gradient(135deg, #8B0000 0%, #DC143C 100%); color: white; padding: 30px; border-radius: 10px 10px 0 0;">
691 <h1 style="margin: 0; font-size: 28px;">❌ Booking Cancelled</h1>
692 </div>
693
694 <div style="background: white; padding: 30px; border-radius: 0 0 10px 10px;">
695 <p style="font-size: 16px; color: #666;">Your booking with Hibiscus to Airport has been cancelled.</p>
696
697 <div style="background: #ffe6e6; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #DC143C;">
698 <p style="margin: 0;"><strong>Booking Reference:</strong> <span style="color: #DC143C; font-size: 20px; font-weight: bold;">{booking_ref}</span></p>
699 </div>
700
701 <h3 style="color: #333; border-bottom: 2px solid #DC143C; padding-bottom: 10px;">Cancelled Trip Details</h3>
702 <table style="width: 100%; margin: 20px 0;">
703 <tr>
704 <td style="padding: 10px 0; color: #666;"><strong>Name:</strong></td>
705 <td style="padding: 10px 0;">{booking['name']}</td>
706 </tr>
707 <tr>
708 <td style="padding: 10px 0; color: #666;"><strong>Pickup:</strong></td>
709 <td style="padding: 10px 0;">{booking['pickupAddress']}</td>
710 </tr>
711 <tr>
712 <td style="padding: 10px 0; color: #666;"><strong>Drop-off:</strong></td>
713 <td style="padding: 10px 0;">{booking['dropoffAddress']}</td>
714 </tr>
715 <tr>
716 <td style="padding: 10px 0; color: #666;"><strong>Date & Time:</strong></td>
717 <td style="padding: 10px 0;">{formatted_date} at {booking['time']}</td>
718 </tr>
719 </table>
720
721 <div style="background: #f0f0f0; padding: 20px; border-radius: 8px; margin: 20px 0;">
722 <p style="margin: 0; color: #666;">If you have any questions about this cancellation or would like to make a new booking, please contact us.</p>
723 </div>
724
725 <p style="margin-top: 20px; color: #666;">
726 <strong>Contact Us:</strong><br>
727 Email: info@bookaride.co.nz<br>
728 Book online: hibiscustoairport.co.nz
729 </p>
730 </div>
731 </div>
732 """
733
734 return send_email(booking['email'], subject, body)
735
736def send_cancellation_sms(booking: dict):
737 """Send booking cancellation SMS to customer"""
738 booking_ref = booking.get('booking_ref', 'N/A')
739 formatted_date = format_date_nz(booking['date'])
740
741 message = f"""Hibiscus to Airport - Booking Cancelled
742Ref: {booking_ref}
743Date: {formatted_date} at {booking['time']}
744
745Your booking has been cancelled. Contact us if you have questions: info@bookaride.co.nz"""
746
747 return send_sms(booking['phone'], message)
748
749# ============================================
750# URGENT BOOKING NOTIFICATIONS (Within 24 hours)
751# ============================================
752
753def is_urgent_booking(booking_date: str, booking_time: str = "00:00") -> tuple:
754 """
755 Check if booking is urgent (within 24 hours of travel).
756 Returns (is_urgent, hours_until_trip)
757 """
758 try:
759 from datetime import datetime, timezone
760 import pytz
761
762 # Parse booking date and time
763 booking_datetime_str = f"{booking_date} {booking_time}"
764 booking_dt = datetime.strptime(booking_datetime_str, "%Y-%m-%d %H:%M")
765
766 # Make it NZ timezone aware
767 nz_tz = pytz.timezone('Pacific/Auckland')
768 booking_dt = nz_tz.localize(booking_dt)
769
770 # Get current time in NZ
771 now_nz = datetime.now(nz_tz)
772
773 # Calculate hours until trip
774 time_diff = booking_dt - now_nz
775 hours_until = time_diff.total_seconds() / 3600
776
777 # Urgent if within 24 hours
778 is_urgent = 0 < hours_until <= 24
779
780 return is_urgent, round(hours_until, 1)
781 except Exception as e:
782 logger.error(f"Error checking urgent booking: {str(e)}")
783 return False, 0
784
785def send_urgent_admin_email(booking: dict, hours_until: float):
786 """Send URGENT booking alert email to admin"""
787 booking_ref = booking.get('booking_ref', 'N/A')
788 formatted_date = format_date_with_day(booking['date'])
789 admin_email = os.environ.get('ADMIN_EMAIL', 'bookings@bookaride.co.nz')
790
791 subject = f"🚨 URGENT BOOKING - {booking_ref} - {int(hours_until)}hrs NOTICE!"
792
793 body = f"""
794 <div style="max-width: 600px; margin: 0 auto; padding: 20px; font-family: Arial, sans-serif;">
795 <div style="background: #DC2626; color: white; padding: 25px; border-radius: 10px 10px 0 0; text-align: center;">
796 <h1 style="margin: 0; font-size: 28px;">🚨 URGENT BOOKING</h1>
797 <p style="margin: 10px 0 0; font-size: 18px; font-weight: bold;">Only {int(hours_until)} hours until pickup!</p>
798 </div>
799
800 <div style="background: #FEF2F2; padding: 20px; border: 2px solid #DC2626; border-top: none; border-radius: 0 0 10px 10px;">
801 <div style="background: white; padding: 20px; border-radius: 8px; border-left: 4px solid #DC2626;">
802 <p><strong>Reference:</strong> <span style="font-size: 20px; color: #DC2626; font-weight: bold;">{booking_ref}</span></p>
803 <hr style="border: none; border-top: 1px solid #ddd; margin: 15px 0;">
804
805 <p><strong>👤 Customer:</strong> {booking['name']}</p>
806 <p><strong>📞 Phone:</strong> <a href="tel:{booking['phone']}" style="color: #DC2626; font-weight: bold;">{booking['phone']}</a></p>
807 <p><strong>✉️ Email:</strong> {booking['email']}</p>
808
809 <hr style="border: none; border-top: 1px solid #ddd; margin: 15px 0;">
810
811 <p><strong>📍 Pickup:</strong> {booking['pickupAddress']}</p>
812 <p><strong>🏁 Drop-off:</strong> {booking['dropoffAddress']}</p>
813 <p style="font-size: 18px;"><strong>📅 Pickup Time:</strong> <span style="color: #DC2626; font-weight: bold;">{formatted_date} at {booking['time']}</span></p>
814 <p><strong>👥 Passengers:</strong> {booking['passengers']}</p>
815
816 <hr style="border: none; border-top: 1px solid #ddd; margin: 15px 0;">
817
818 <p><strong>💰 Total Price:</strong> <span style="font-size: 18px; color: green;">${booking.get('totalPrice', booking.get('pricing', {}).get('totalPrice', 0)):.2f} NZD</span></p>
819 </div>
820
821 <div style="text-align: center; margin-top: 20px;">
822 <p style="color: #DC2626; font-weight: bold; font-size: 16px;">⚠️ ACTION REQUIRED: Assign driver immediately!</p>
823 </div>
824 </div>
825 </div>
826 """
827
828 return send_email(admin_email, subject, body)
829
830def send_urgent_admin_sms(booking: dict, hours_until: float):
831 """Send URGENT booking SMS alert to admin"""
832 admin_phone = os.environ.get('ADMIN_PHONE')
833 if not admin_phone:
834 logger.warning("ADMIN_PHONE not set - skipping urgent admin SMS")
835 return False
836
837 booking_ref = booking.get('booking_ref', 'N/A')
838 formatted_date = format_date_nz(booking['date'])
839 total = booking.get('totalPrice', booking.get('pricing', {}).get('totalPrice', 0))
840
841 message = f"""🚨 URGENT BOOKING!
842
843⏰ ONLY {int(hours_until)}hrs NOTICE!
844
845Ref: {booking_ref}
846{booking['name']}
847📞 {booking['phone']}
848
849{formatted_date} at {booking['time']}
850{booking['passengers']} pax | ${total:.2f}
851
852From: {booking['pickupAddress'][:35]}...
853To: {booking['dropoffAddress'][:35]}...
854
855ACTION REQUIRED NOW!"""
856
857 try:
858 send_sms(admin_phone, message)
859 logger.info(f"URGENT admin SMS sent to {admin_phone} for booking {booking_ref}")
860 return True
861 except Exception as e:
862 logger.error(f"Failed to send urgent admin SMS: {str(e)}")
863 return False
864
865async def send_password_reset_email(email: str, reset_token: str):
866 """Send password reset email to admin"""
867 # Get frontend URL from environment
868 frontend_url = os.environ.get('FRONTEND_URL', 'https://hibiscustoairport.co.nz')
869 reset_link = f"{frontend_url}/admin/reset-password?token={reset_token}"
870
871 subject = "🔐 Password Reset - Hibiscus to Airport Admin"
872
873 body = f"""
874 <div style="max-width: 600px; margin: 0 auto; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #f8fafc;">
875 <!-- Header -->
876 <div style="background: linear-gradient(135deg, #1f2937 0%, #111827 100%); color: white; padding: 40px 30px; text-align: center; border-radius: 10px 10px 0 0;">
877 <h1 style="margin: 0; font-size: 24px; font-weight: 300; letter-spacing: 2px; text-transform: uppercase;">
878 🔐 PASSWORD RESET
879 </h1>
880 <p style="margin: 8px 0 0; font-size: 14px; color: #f59e0b; font-weight: 500; letter-spacing: 1px;">
881 HIBISCUS TO AIRPORT ADMIN
882 </p>
883 </div>
884
885 <!-- Main Content -->
886 <div style="background: white; padding: 40px 30px; border-radius: 0 0 10px 10px;">
887 <p style="font-size: 16px; color: #374151; line-height: 1.6;">
888 Hello,
889 </p>
890 <p style="font-size: 16px; color: #374151; line-height: 1.6;">
891 We received a request to reset your admin password. Click the button below to set a new password:
892 </p>
893
894 <div style="text-align: center; margin: 30px 0;">
895 <a href="{reset_link}" style="display: inline-block; background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: 15px 40px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 16px;">
896 Reset Password
897 </a>
898 </div>
899
900 <p style="font-size: 14px; color: #6b7280; line-height: 1.6;">
901 Or copy and paste this link into your browser:
902 </p>
903 <p style="font-size: 12px; color: #f59e0b; word-break: break-all; background: #f8fafc; padding: 15px; border-radius: 8px;">
904 {reset_link}
905 </p>
906
907 <div style="background: #fef3c7; padding: 15px; border-radius: 8px; margin: 30px 0; border-left: 4px solid #f59e0b;">
908 <p style="margin: 0; font-size: 14px; color: #92400e;">
909 ⚠️ This link will expire in <strong>1 hour</strong>. If you didn't request this reset, please ignore this email.
910 </p>
911 </div>
912
913 <hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
914
915 <p style="font-size: 12px; color: #9ca3af; text-align: center;">
916 Hibiscus to Airport - Premium Airport Transfers<br>
917 This is an automated message. Please do not reply.
918 </p>
919 </div>
920 </div>
921 """
922
923 return send_email(email, subject, body)
924
Modifiedfrontend/package.json+0−1View fileUnifiedSplit
3131 "@radix-ui/react-toggle": "^1.1.6",
3232 "@radix-ui/react-toggle-group": "^1.1.7",
3333 "@radix-ui/react-tooltip": "^1.2.4",
34 "@react-google-maps/api": "^2.20.7",
3534 "@stripe/stripe-js": "^8.5.3",
3635 "axios": "^1.8.4",
3736 "class-variance-authority": "^0.7.1",
Modifiedfrontend/src/admin/ErrorBoundary.jsx+2−3View fileUnifiedSplit
1010 return { hasError: true, error };
1111 }
1212
13 componentDidCatch(error, info) {
14 // Keep console errors for debugging
15 console.error("Admin ErrorBoundary caught:", error, info);
13 componentDidCatch() {
14 // Error details are displayed in the fallback UI
1615 }
1716
1817 render() {
Modifiedfrontend/src/components/About.jsx+36−36View fileUnifiedSplit
66 <section id="about" className="py-20 bg-white reveal-on-scroll">
77 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
88 <div className="text-center mb-16">
9 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
10 About <span className="text-gold">Hibiscus to Airport</span>
9 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
10 About Hibiscus to Airport
1111 </h2>
12 <div className="w-16 h-0.5 bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
13 <p className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed">
12 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
13 <p className="text-xl text-[#4A5568] max-w-3xl mx-auto leading-relaxed">
1414 Your trusted airport shuttle service connecting the Hibiscus Coast to Auckland Airport
1515 </p>
1616 </div>
1717
1818 <div className="grid md:grid-cols-2 gap-12 items-center mb-16">
1919 <div>
20 <h3 className="text-3xl font-bold text-gray-900 mb-6">Why Choose Us</h3>
21 <p className="text-gray-600 mb-4 leading-relaxed">
20 <h3 className="text-3xl font-bold text-[#1B2B4B] mb-6">Why Choose Us</h3>
21 <p className="text-[#4A5568] mb-4 leading-relaxed">
2222 Hibiscus to Airport was founded with a simple mission: to provide reliable, comfortable, and affordable airport transportation for residents and visitors of the beautiful Hibiscus Coast region.
2323 </p>
24 <p className="text-gray-600 mb-4 leading-relaxed">
24 <p className="text-[#4A5568] mb-4 leading-relaxed">
2525 We understand the importance of punctuality when it comes to catching flights, which is why we've built our reputation on being consistently on time, every time. Our professional drivers know the area intimately and monitor your flight status to ensure seamless service.
2626 </p>
27 <p className="text-gray-600 leading-relaxed">
27 <p className="text-[#4A5568] leading-relaxed">
2828 Whether you're a local heading to the airport or a visitor arriving in Auckland, we're here to make your journey comfortable and stress-free.
2929 </p>
3030 </div>
3131
3232 <div className="grid grid-cols-2 gap-6">
33 <div className="bg-white p-8 rounded-xl border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
34 <div className="w-14 h-14 rounded-full bg-gold/10 flex items-center justify-center mb-4">
35 <Shield className="w-7 h-7 text-gold" />
33 <div className="bg-[#FAFBFC] p-8 rounded-xl border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
34 <div className="w-14 h-14 rounded-full bg-[#1B2B4B]/5 flex items-center justify-center mb-4">
35 <Shield className="w-7 h-7 text-[#1B2B4B]" />
3636 </div>
37 <h4 className="font-bold text-xl text-gray-900 mb-2">Fully Licensed</h4>
38 <p className="text-gray-600 text-sm leading-relaxed">All drivers are licensed and insured for your safety</p>
37 <h4 className="font-bold text-xl text-[#1B2B4B] mb-2">Fully Licensed</h4>
38 <p className="text-[#4A5568] text-sm leading-relaxed">All drivers are licensed and insured for your safety</p>
3939 </div>
40 <div className="bg-white p-8 rounded-xl border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
41 <div className="w-14 h-14 rounded-full bg-gold/10 flex items-center justify-center mb-4">
42 <Award className="w-7 h-7 text-gold" />
40 <div className="bg-[#FAFBFC] p-8 rounded-xl border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
41 <div className="w-14 h-14 rounded-full bg-[#1B2B4B]/5 flex items-center justify-center mb-4">
42 <Award className="w-7 h-7 text-[#1B2B4B]" />
4343 </div>
44 <h4 className="font-bold text-xl text-gray-900 mb-2">5+ Years</h4>
45 <p className="text-gray-600 text-sm leading-relaxed">Experience serving the Hibiscus Coast community</p>
44 <h4 className="font-bold text-xl text-[#1B2B4B] mb-2">5+ Years</h4>
45 <p className="text-[#4A5568] text-sm leading-relaxed">Experience serving the Hibiscus Coast community</p>
4646 </div>
47 <div className="bg-white p-8 rounded-xl border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
48 <div className="w-14 h-14 rounded-full bg-gold/10 flex items-center justify-center mb-4">
49 <Users className="w-7 h-7 text-gold" />
47 <div className="bg-[#FAFBFC] p-8 rounded-xl border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
48 <div className="w-14 h-14 rounded-full bg-[#1B2B4B]/5 flex items-center justify-center mb-4">
49 <Users className="w-7 h-7 text-[#1B2B4B]" />
5050 </div>
51 <h4 className="font-bold text-xl text-gray-900 mb-2">1000+ Customers</h4>
52 <p className="text-gray-600 text-sm leading-relaxed">Trusted by thousands of satisfied passengers</p>
51 <h4 className="font-bold text-xl text-[#1B2B4B] mb-2">1000+ Customers</h4>
52 <p className="text-[#4A5568] text-sm leading-relaxed">Trusted by thousands of satisfied passengers</p>
5353 </div>
54 <div className="bg-white p-8 rounded-xl border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
55 <div className="w-14 h-14 rounded-full bg-gold/10 flex items-center justify-center mb-4">
56 <Clock className="w-7 h-7 text-gold" />
54 <div className="bg-[#FAFBFC] p-8 rounded-xl border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300">
55 <div className="w-14 h-14 rounded-full bg-[#1B2B4B]/5 flex items-center justify-center mb-4">
56 <Clock className="w-7 h-7 text-[#1B2B4B]" />
5757 </div>
58 <h4 className="font-bold text-xl text-gray-900 mb-2">24/7 Available</h4>
59 <p className="text-gray-600 text-sm leading-relaxed">Ready to serve you any time of day or night</p>
58 <h4 className="font-bold text-xl text-[#1B2B4B] mb-2">24/7 Available</h4>
59 <p className="text-[#4A5568] text-sm leading-relaxed">Ready to serve you any time of day or night</p>
6060 </div>
6161 </div>
6262 </div>
6363
64 <div className="bg-gray-50 rounded-2xl p-12 text-center border border-gray-100">
65 <h3 className="text-3xl font-bold text-gray-900 mb-6">Our Commitment to You</h3>
64 <div className="bg-[#F8FAFB] rounded-2xl p-12 text-center border border-[#E8ECF0]">
65 <h3 className="text-3xl font-bold text-[#1B2B4B] mb-6">Our Commitment to You</h3>
6666 <div className="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
6767 <div>
68 <h4 className="text-gold font-bold text-lg mb-3">Reliability</h4>
69 <p className="text-gray-600">On-time pickups and drop-offs, guaranteed</p>
68 <h4 className="text-[#1B2B4B] font-bold text-lg mb-3">Reliability</h4>
69 <p className="text-[#4A5568]">On-time pickups and drop-offs, guaranteed</p>
7070 </div>
7171 <div>
72 <h4 className="text-gold font-bold text-lg mb-3">Comfort</h4>
73 <p className="text-gray-600">Clean, modern vehicles with plenty of space</p>
72 <h4 className="text-[#1B2B4B] font-bold text-lg mb-3">Comfort</h4>
73 <p className="text-[#4A5568]">Clean, modern vehicles with plenty of space</p>
7474 </div>
7575 <div>
76 <h4 className="text-gold font-bold text-lg mb-3">Value</h4>
77 <p className="text-gray-600">Competitive pricing with no hidden fees</p>
76 <h4 className="text-[#1B2B4B] font-bold text-lg mb-3">Value</h4>
77 <p className="text-[#4A5568]">Competitive pricing with no hidden fees</p>
7878 </div>
7979 </div>
8080 </div>
Modifiedfrontend/src/components/Contact.jsx+25−24View fileUnifiedSplit
88 const navigate = useNavigate();
99
1010 return (
11 <section id="contact" className="py-20 bg-gray-50 reveal-on-scroll">
11 <section id="contact" className="py-20 bg-[#F8FAFB] reveal-on-scroll">
1212 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
1313 <div className="text-center mb-16">
14 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
15 Ready to <span className="text-gold">Book</span> Your Ride?
14 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
15 Ready to Book Your Ride?
1616 </h2>
17 <p className="text-xl text-gray-600 max-w-2xl mx-auto">
17 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
18 <p className="text-xl text-[#4A5568] max-w-2xl mx-auto">
1819 Book online in under 2 minutes. We're here 24/7.
1920 </p>
2021 </div>
2425 <div className="text-center lg:text-left space-y-6">
2526 <Button
2627 onClick={() => navigate('/booking')}
27 className="w-full sm:w-auto bg-gray-900 hover:bg-gray-800 text-white py-6 px-12 text-lg font-semibold tracking-wide shadow-sm"
28 className="w-full sm:w-auto bg-gradient-to-r from-[#1B2B4B] to-[#2D4A7A] hover:from-[#162340] hover:to-[#264068] text-white py-6 px-12 text-lg font-semibold tracking-wide shadow-sm"
2829 >
2930 Book Now
3031 </Button>
3132 <div className="flex items-center justify-center lg:justify-start gap-3 pt-2">
32 <Mail className="w-5 h-5 text-gold" />
33 <a href="mailto:info@bookaride.co.nz" className="text-lg text-gray-700 hover:text-gray-900 font-medium transition-colors duration-300">
33 <Mail className="w-5 h-5 text-[#1B2B4B]" />
34 <a href="mailto:info@bookaride.co.nz" className="text-lg text-[#4A5568] hover:text-[#1B2B4B] font-medium transition-colors duration-300">
3435 info@bookaride.co.nz
3536 </a>
3637 </div>
3839
3940 {/* Contact Information */}
4041 <div className="space-y-4">
41 <Card className="bg-white border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300">
42 <Card className="bg-white border border-[#E8ECF0] shadow-sm hover:shadow-md transition-all duration-300">
4243 <CardContent className="p-6">
4344 <div className="flex items-start">
44 <div className="w-12 h-12 bg-gold/10 rounded-full flex items-center justify-center mr-4 flex-shrink-0">
45 <Mail className="w-6 h-6 text-gold" />
45 <div className="w-12 h-12 bg-[#1B2B4B]/5 rounded-full flex items-center justify-center mr-4 flex-shrink-0">
46 <Mail className="w-6 h-6 text-[#1B2B4B]" />
4647 </div>
4748 <div>
48 <h3 className="font-bold text-gray-900 mb-1">Email</h3>
49 <a href="mailto:info@bookaride.co.nz" className="text-gray-600 hover:text-gray-900 transition-colors duration-300">info@bookaride.co.nz</a>
49 <h3 className="font-bold text-[#1B2B4B] mb-1">Email</h3>
50 <a href="mailto:info@bookaride.co.nz" className="text-[#4A5568] hover:text-[#1B2B4B] transition-colors duration-300">info@bookaride.co.nz</a>
5051 </div>
5152 </div>
5253 </CardContent>
5354 </Card>
5455
55 <Card className="bg-white border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300">
56 <Card className="bg-white border border-[#E8ECF0] shadow-sm hover:shadow-md transition-all duration-300">
5657 <CardContent className="p-6">
5758 <div className="flex items-start">
58 <div className="w-12 h-12 bg-gold/10 rounded-full flex items-center justify-center mr-4 flex-shrink-0">
59 <MapPin className="w-6 h-6 text-gold" />
59 <div className="w-12 h-12 bg-[#1B2B4B]/5 rounded-full flex items-center justify-center mr-4 flex-shrink-0">
60 <MapPin className="w-6 h-6 text-[#1B2B4B]" />
6061 </div>
6162 <div>
62 <h3 className="font-bold text-gray-900 mb-1">Service Area</h3>
63 <p className="text-gray-600">Hibiscus Coast - Orewa & Surrounds</p>
64 <p className="text-sm text-gray-500 mt-1">Servicing all suburbs to Auckland Airport</p>
63 <h3 className="font-bold text-[#1B2B4B] mb-1">Service Area</h3>
64 <p className="text-[#4A5568]">Hibiscus Coast - Orewa & Surrounds</p>
65 <p className="text-sm text-[#8896A6] mt-1">Servicing all suburbs to Auckland Airport</p>
6566 </div>
6667 </div>
6768 </CardContent>
6869 </Card>
6970
70 <Card className="bg-white border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300">
71 <Card className="bg-white border border-[#E8ECF0] shadow-sm hover:shadow-md transition-all duration-300">
7172 <CardContent className="p-6">
7273 <div className="flex items-start">
73 <div className="w-12 h-12 bg-gold/10 rounded-full flex items-center justify-center mr-4 flex-shrink-0">
74 <Clock className="w-6 h-6 text-gold" />
74 <div className="w-12 h-12 bg-[#1B2B4B]/5 rounded-full flex items-center justify-center mr-4 flex-shrink-0">
75 <Clock className="w-6 h-6 text-[#1B2B4B]" />
7576 </div>
7677 <div>
77 <h3 className="font-bold text-gray-900 mb-1">Operating Hours</h3>
78 <p className="text-gray-600">24 Hours / 7 Days a Week</p>
79 <p className="text-sm text-gray-500 mt-1">Including public holidays</p>
78 <h3 className="font-bold text-[#1B2B4B] mb-1">Operating Hours</h3>
79 <p className="text-[#4A5568]">24 Hours / 7 Days a Week</p>
80 <p className="text-sm text-[#8896A6] mt-1">Including public holidays</p>
8081 </div>
8182 </div>
8283 </CardContent>
Modifiedfrontend/src/components/Features.jsx+32−32View fileUnifiedSplit
3030 <section className="py-20 bg-white reveal-on-scroll">
3131 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
3232 <div className="text-center mb-16">
33 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
34 Why Choose <span className="text-gold">Hibiscus to Airport</span>?
33 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
34 Why Choose Hibiscus to Airport?
3535 </h2>
36 <div className="w-16 h-0.5 bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
37 <p className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed">
36 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
37 <p className="text-xl text-[#4A5568] max-w-3xl mx-auto leading-relaxed">
3838 Experience the perfect blend of reliability, comfort, and modern convenience
3939 </p>
4040 </div>
4141
4242 {/* Competitive Comparison Section */}
43 <div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 mb-12">
44 <h3 className="text-2xl font-bold text-gray-900 text-center mb-8">
43 <div className="bg-[#FAFBFC] rounded-2xl border border-[#E8ECF0] shadow-sm p-8 mb-12">
44 <h3 className="text-2xl font-bold text-[#1B2B4B] text-center mb-8">
4545 Hibiscus to Airport vs Traditional Shuttles
4646 </h3>
4747 <div className="grid md:grid-cols-2 gap-8">
4848 <div>
49 <h4 className="text-lg font-bold text-gray-900 mb-4 text-center">Hibiscus to Airport</h4>
49 <h4 className="text-lg font-bold text-[#1B2B4B] mb-4 text-center">Hibiscus to Airport</h4>
5050 <ul className="space-y-3">
51 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> Luxury Toyota Hiace vehicles</li>
52 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> Professional uniformed drivers</li>
53 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> Advanced online booking system</li>
54 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> Flight monitoring & real-time updates</li>
55 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> Complimentary Wi-Fi & charging</li>
56 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> 24/7 premium customer service</li>
57 <li className="flex items-center text-gray-700"><CheckCircle className="w-5 h-5 text-gold mr-3 flex-shrink-0" /> Guaranteed pickup times</li>
51 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> Luxury Toyota Hiace vehicles</li>
52 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> Professional uniformed drivers</li>
53 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> Advanced online booking system</li>
54 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> Flight monitoring & real-time updates</li>
55 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> Complimentary Wi-Fi & charging</li>
56 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> 24/7 premium customer service</li>
57 <li className="flex items-center text-[#4A5568]"><CheckCircle className="w-5 h-5 text-[#1B2B4B] mr-3 flex-shrink-0" /> Guaranteed pickup times</li>
5858 </ul>
5959 </div>
6060 <div>
61 <h4 className="text-lg font-bold text-gray-400 mb-4 text-center">Traditional Shuttles</h4>
62 <ul className="space-y-3 text-gray-400">
63 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Basic vehicle options</li>
64 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Casual driver presentation</li>
65 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Phone-only booking</li>
66 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Manual scheduling systems</li>
67 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Limited amenities</li>
68 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Business hours only</li>
69 <li className="flex items-center"><X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" /> Estimated arrival times</li>
61 <h4 className="text-lg font-bold text-[#8896A6] mb-4 text-center">Traditional Shuttles</h4>
62 <ul className="space-y-3 text-[#8896A6]">
63 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Basic vehicle options</li>
64 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Casual driver presentation</li>
65 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Phone-only booking</li>
66 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Manual scheduling systems</li>
67 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Limited amenities</li>
68 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Business hours only</li>
69 <li className="flex items-center"><X className="w-5 h-5 text-[#8896A6]/50 mr-3 flex-shrink-0" /> Estimated arrival times</li>
7070 </ul>
7171 </div>
7272 </div>
7676 {features.map((feature) => {
7777 const IconComponent = iconMap[feature.icon];
7878 return (
79 <Card key={feature.id} className="bg-white border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 group">
79 <Card key={feature.id} className="bg-[#FAFBFC] border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 group">
8080 <CardHeader className="p-8">
81 <div className="w-12 h-12 bg-gold/10 rounded-full flex items-center justify-center mb-4">
82 <IconComponent className="w-6 h-6 text-gold" />
81 <div className="w-12 h-12 bg-[#1B2B4B]/5 rounded-full flex items-center justify-center mb-4">
82 <IconComponent className="w-6 h-6 text-[#1B2B4B]" />
8383 </div>
84 <CardTitle className="text-lg font-bold text-gray-900 mb-2">
84 <CardTitle className="text-lg font-bold text-[#1B2B4B] mb-2">
8585 {feature.title}
8686 </CardTitle>
87 <CardDescription className="text-gray-600 text-sm">
87 <CardDescription className="text-[#4A5568] text-sm">
8888 {feature.description}
8989 </CardDescription>
9090 </CardHeader>
9393 })}
9494 </div>
9595
96 <div className="text-center bg-gray-50 rounded-2xl p-12 border border-gray-100">
96 <div className="text-center bg-[#F8FAFB] rounded-2xl p-12 border border-[#E8ECF0]">
9797 <div className="relative">
98 <h3 className="text-3xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
98 <h3 className="text-3xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
9999 Ready to Book Your Transfer?
100100 </h3>
101 <p className="text-gray-600 mb-8 text-lg max-w-2xl mx-auto">
101 <p className="text-[#4A5568] mb-8 text-lg max-w-2xl mx-auto">
102102 Experience reliable, professional airport transportation
103103 </p>
104104 <Button
105105 onClick={() => window.location.href = '/booking'}
106 className="bg-gray-900 hover:bg-gray-800 text-white px-10 py-6 text-lg font-semibold shadow-sm">
106 className="bg-gradient-to-r from-[#1B2B4B] to-[#2D4A7A] hover:from-[#162340] hover:to-[#264068] text-white px-10 py-6 text-lg font-semibold shadow-sm">
107107 Book Now
108108 </Button>
109109 </div>
Modifiedfrontend/src/components/Fleet.jsx+45−44View fileUnifiedSplit
77 id: 1,
88 name: 'Standard Transfer',
99 badge: 'MOST POPULAR',
10 badgeColor: 'bg-gold/10 text-gold border border-gold/20',
10 badgeColor: 'bg-[#1B2B4B]/5 text-[#1B2B4B] border border-[#1B2B4B]/10',
1111 description: 'Comfortable daytime airport shuttle for individuals and small groups',
1212 passengers: '1-4',
1313 luggage: '4-6',
1818 id: 2,
1919 name: 'Premium Van',
2020 badge: '24/7 AVAILABLE',
21 badgeColor: 'bg-gray-100 text-gray-700 border border-gray-200',
21 badgeColor: 'bg-[#1B2B4B]/5 text-[#1B2B4B] border border-[#1B2B4B]/10',
2222 description: 'Spacious van for families and medium groups with extra luggage room',
2323 passengers: '5-8',
2424 luggage: '8-10',
2929 id: 3,
3030 name: 'Executive Group',
3131 badge: 'AIRPORT SPECIAL',
32 badgeColor: 'bg-gray-900/5 text-gray-900 border border-gray-200',
32 badgeColor: 'bg-[#1B2B4B]/5 text-[#1B2B4B] border border-[#1B2B4B]/10',
3333 description: 'Full-size Toyota Hiace for large groups, events, and corporate travel',
3434 passengers: '9-11',
3535 luggage: '12+',
4040
4141const Fleet = () => {
4242 return (
43 <section id="fleet" className="py-20 bg-gray-50 reveal-on-scroll">
43 <section id="fleet" className="py-20 bg-[#F8FAFB] reveal-on-scroll">
4444 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
4545 <div className="text-center mb-16">
46 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
47 Our <span className="text-gold">Fleet</span>
46 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
47 Our Fleet
4848 </h2>
49 <p className="text-xl text-gray-600 max-w-2xl mx-auto">
49 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
50 <p className="text-xl text-[#4A5568] max-w-2xl mx-auto">
5051 Clean, comfortable, and reliable vehicles for every group size
5152 </p>
5253 </div>
5455 <div className="relative">
5556 {/* Big Group Banner */}
5657 <div className="flex justify-center mb-8">
57 <div className="bg-white text-gray-900 px-8 py-3 rounded-full font-semibold text-lg shadow-sm border border-gray-200">
58 BIG GROUP? Book Multiple Vans! <span className="ml-2 bg-gold/10 text-gold px-3 py-1 rounded-full text-sm font-bold">22+ PASSENGERS</span>
58 <div className="bg-white text-[#1B2B4B] px-8 py-3 rounded-full font-semibold text-lg shadow-sm border border-[#E8ECF0]">
59 BIG GROUP? Book Multiple Vans! <span className="ml-2 bg-[#1B2B4B]/5 text-[#1B2B4B] px-3 py-1 rounded-full text-sm font-bold">22+ PASSENGERS</span>
5960 </div>
6061 </div>
6162
6465 {fleet.map((vehicle) => {
6566 const IconComponent = vehicle.icon;
6667 return (
67 <Card key={vehicle.id} className="overflow-hidden bg-white border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300 relative rounded-xl group">
68 <Card key={vehicle.id} className="overflow-hidden bg-[#FAFBFC] border border-[#E8ECF0] shadow-sm hover:shadow-md transition-all duration-300 relative rounded-xl group">
6869 {vehicle.badge && (
6970 <div className={`absolute top-4 right-4 ${vehicle.badgeColor} px-3 py-1 rounded-full text-xs font-bold z-10`}>
7071 {vehicle.badge}
7172 </div>
7273 )}
7374 {/* Icon visual area */}
74 <div className={`aspect-[4/3] ${vehicle.iconBg} flex items-center justify-center relative overflow-hidden border-b border-gray-100`}>
75 <IconComponent className="w-24 h-24 text-gray-300 group-hover:text-gold group-hover:scale-110 transition-all duration-500 relative z-10" strokeWidth={1.5} />
75 <div className={`aspect-[4/3] ${vehicle.iconBg} flex items-center justify-center relative overflow-hidden border-b border-[#E8ECF0]`}>
76 <IconComponent className="w-24 h-24 text-[#8896A6] group-hover:text-[#1B2B4B] group-hover:scale-110 transition-all duration-500 relative z-10" strokeWidth={1.5} />
7677 </div>
7778 <CardContent className="p-6">
78 <h3 className="text-xl font-bold text-gray-900 mb-2">{vehicle.name}</h3>
79 <p className="text-gray-600 mb-6">{vehicle.description}</p>
80 <div className="flex items-center justify-around text-sm text-gray-700 border-t border-gray-100 pt-4">
79 <h3 className="text-xl font-bold text-[#1B2B4B] mb-2">{vehicle.name}</h3>
80 <p className="text-[#4A5568] mb-6">{vehicle.description}</p>
81 <div className="flex items-center justify-around text-sm text-[#4A5568] border-t border-[#E8ECF0] pt-4">
8182 <div className="flex items-center">
82 <Users className="w-5 h-5 mr-2 text-gold" />
83 <Users className="w-5 h-5 mr-2 text-[#1B2B4B]" />
8384 <div>
84 <div className="font-bold text-lg text-gray-900">{vehicle.passengers}</div>
85 <div className="text-xs text-gray-500">passengers</div>
85 <div className="font-bold text-lg text-[#1B2B4B]">{vehicle.passengers}</div>
86 <div className="text-xs text-[#8896A6]">passengers</div>
8687 </div>
8788 </div>
8889 <div className="flex items-center">
89 <Luggage className="w-5 h-5 mr-2 text-gold" />
90 <Luggage className="w-5 h-5 mr-2 text-[#1B2B4B]" />
9091 <div>
91 <div className="font-bold text-lg text-gray-900">{vehicle.luggage}</div>
92 <div className="text-xs text-gray-500">bags</div>
92 <div className="font-bold text-lg text-[#1B2B4B]">{vehicle.luggage}</div>
93 <div className="text-xs text-[#8896A6]">bags</div>
9394 </div>
9495 </div>
9596 </div>
100101 </div>
101102
102103 {/* Additional Capacity Info */}
103 <div className="bg-white rounded-2xl p-8 sm:p-12 text-center border border-gray-100 shadow-sm mb-12">
104 <h3 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3">Need More Than 11 Passengers? We've Got You Covered!</h3>
105 <p className="text-gray-600 mb-8 text-lg">Perfect for corporate events, weddings, concerts, and large group outings.</p>
104 <div className="bg-white rounded-2xl p-8 sm:p-12 text-center border border-[#E8ECF0] shadow-sm mb-12">
105 <h3 className="text-2xl sm:text-3xl font-bold text-[#1B2B4B] mb-3">Need More Than 11 Passengers? We've Got You Covered!</h3>
106 <p className="text-[#4A5568] mb-8 text-lg">Perfect for corporate events, weddings, concerts, and large group outings.</p>
106107 <div className="grid grid-cols-1 sm:grid-cols-3 gap-6 sm:gap-8 max-w-4xl mx-auto">
107 <div className="bg-gray-50 rounded-xl p-6 border border-gray-100">
108 <div className="text-5xl font-bold text-gold mb-2">22</div>
109 <div className="text-gray-900 font-semibold mb-1">2 Vans Available</div>
110 <div className="text-sm text-gray-500">For medium groups</div>
108 <div className="bg-[#F8FAFB] rounded-xl p-6 border border-[#E8ECF0]">
109 <div className="text-5xl font-bold text-[#1B2B4B] mb-2">22</div>
110 <div className="text-[#1B2B4B] font-semibold mb-1">2 Vans Available</div>
111 <div className="text-sm text-[#8896A6]">For medium groups</div>
111112 </div>
112 <div className="bg-gray-50 rounded-xl p-6 border border-gray-100">
113 <div className="text-5xl font-bold text-gold mb-2">33</div>
114 <div className="text-gray-900 font-semibold mb-1">3 Vans Available</div>
115 <div className="text-sm text-gray-500">For large events</div>
113 <div className="bg-[#F8FAFB] rounded-xl p-6 border border-[#E8ECF0]">
114 <div className="text-5xl font-bold text-[#1B2B4B] mb-2">33</div>
115 <div className="text-[#1B2B4B] font-semibold mb-1">3 Vans Available</div>
116 <div className="text-sm text-[#8896A6]">For large events</div>
116117 </div>
117 <div className="bg-gray-50 rounded-xl p-6 border border-gray-100">
118 <div className="text-5xl font-bold text-gold mb-2">44+</div>
119 <div className="text-gray-900 font-semibold mb-1">4+ Vans Available</div>
120 <div className="text-sm text-gray-500">For major events</div>
118 <div className="bg-[#F8FAFB] rounded-xl p-6 border border-[#E8ECF0]">
119 <div className="text-5xl font-bold text-[#1B2B4B] mb-2">44+</div>
120 <div className="text-[#1B2B4B] font-semibold mb-1">4+ Vans Available</div>
121 <div className="text-sm text-[#8896A6]">For major events</div>
121122 </div>
122123 </div>
123124 </div>
124125
125126 {/* Features */}
126127 <div className="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
127 <div className="flex items-center bg-white border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300 rounded-lg p-6">
128 <Shield className="w-10 h-10 text-gold mr-4 flex-shrink-0" />
128 <div className="flex items-center bg-white border border-[#E8ECF0] shadow-sm hover:shadow-md transition-all duration-300 rounded-lg p-6">
129 <Shield className="w-10 h-10 text-[#1B2B4B] mr-4 flex-shrink-0" />
129130 <div>
130 <h4 className="font-bold text-gray-900 mb-1">Fully Licensed & Insured</h4>
131 <p className="text-sm text-gray-600">All vehicles meet safety standards</p>
131 <h4 className="font-bold text-[#1B2B4B] mb-1">Fully Licensed & Insured</h4>
132 <p className="text-sm text-[#4A5568]">All vehicles meet safety standards</p>
132133 </div>
133134 </div>
134 <div className="flex items-center bg-white border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300 rounded-lg p-6">
135 <Clock className="w-10 h-10 text-gold mr-4 flex-shrink-0" />
135 <div className="flex items-center bg-white border border-[#E8ECF0] shadow-sm hover:shadow-md transition-all duration-300 rounded-lg p-6">
136 <Clock className="w-10 h-10 text-[#1B2B4B] mr-4 flex-shrink-0" />
136137 <div>
137 <h4 className="font-bold text-gray-900 mb-1">24/7 Service Available</h4>
138 <p className="text-sm text-gray-600">Day and night airport transfers</p>
138 <h4 className="font-bold text-[#1B2B4B] mb-1">24/7 Service Available</h4>
139 <p className="text-sm text-[#4A5568]">Day and night airport transfers</p>
139140 </div>
140141 </div>
141142 </div>
Modifiedfrontend/src/components/GeoContent.jsx+6−6View fileUnifiedSplit
33const GeoContent = () => (
44 <section className="bg-white py-16 px-4" id="about-service">
55 <div className="max-w-4xl mx-auto">
6 <h2 className="text-3xl font-bold text-gray-900 mb-8 text-center">
6 <h2 className="text-3xl font-bold text-[#1B2B4B] mb-8 text-center">
77 Hibiscus Coast to Auckland Airport: Complete Guide
88 </h2>
99
10 <div className="prose prose-lg max-w-none text-gray-700 space-y-6">
10 <div className="prose prose-lg max-w-none text-[#4A5568] space-y-6">
1111 <p>
1212 <strong>Hibiscus to Airport</strong> is a private airport shuttle service operating
1313 24 hours a day, 7 days a week from the Hibiscus Coast region of Auckland, New Zealand
1515 Orewa, Whangaparaoa, Silverdale, Red Beach, Gulf Harbour, and Stanmore Bay.
1616 </p>
1717
18 <h3 className="text-xl font-semibold text-gray-900">Distance and Travel Time</h3>
18 <h3 className="text-xl font-semibold text-[#1B2B4B]">Distance and Travel Time</h3>
1919 <p>
2020 The distance from the Hibiscus Coast to Auckland Airport is approximately 55-65
2121 kilometres depending on the specific suburb. Travel time is typically 45-60 minutes
2424 bus lanes where available to reduce travel time.
2525 </p>
2626
27 <h3 className="text-xl font-semibold text-gray-900">Pricing</h3>
27 <h3 className="text-xl font-semibold text-[#1B2B4B]">Pricing</h3>
2828 <p>
2929 Airport shuttle fares from the Hibiscus Coast start from NZ$100 (minimum fare) for
3030 nearby suburbs and increase based on distance. There is no surge pricing — rates are
3333 monitoring, Wi-Fi, and phone charging.
3434 </p>
3535
36 <h3 className="text-xl font-semibold text-gray-900">How to Book</h3>
36 <h3 className="text-xl font-semibold text-[#1B2B4B]">How to Book</h3>
3737 <p>
3838 Bookings can be made online at hibiscustoairport.co.nz with instant confirmation. The service accepts credit card payments via Stripe as
3939 well as cash payment to the driver. Advance booking is recommended, especially for
4040 early morning flights before 7am.
4141 </p>
4242
43 <h3 className="text-xl font-semibold text-gray-900">Service Comparison</h3>
43 <h3 className="text-xl font-semibold text-[#1B2B4B]">Service Comparison</h3>
4444 <p>
4545 Unlike shared shuttle services such as SuperShuttle, Hibiscus to Airport provides
4646 private, direct transfers — passengers do not share the vehicle with other travellers
Modifiedfrontend/src/components/Header.jsx+64−110View fileUnifiedSplit
11import React, { useState, useEffect } from 'react';
2import { Link } from 'react-router-dom';
23import { Menu, X } from 'lucide-react';
34
45const Header = () => {
67 const [scrolled, setScrolled] = useState(false);
78
89 useEffect(() => {
9 const handleScroll = () => {
10 setScrolled(window.scrollY > 10);
11 };
10 const handleScroll = () => setScrolled(window.scrollY > 10);
1211 window.addEventListener('scroll', handleScroll);
1312 return () => window.removeEventListener('scroll', handleScroll);
1413 }, []);
1514
16 const scrollToSection = (sectionId) => {
17 const element = document.getElementById(sectionId);
18 if (element) {
19 element.scrollIntoView({ behavior: 'smooth' });
20 setMobileMenuOpen(false);
21 } else {
22 window.location.href = `/#${sectionId}`;
23 }
24 };
25
2615 return (
27 <header
28 className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
29 scrolled
30 ? 'bg-white/95 backdrop-blur-md shadow-md'
31 : 'bg-white shadow-sm'
32 } border-b border-gray-200`}
33 >
16 <header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
17 scrolled ? 'bg-white/95 backdrop-blur-md shadow-md' : 'bg-white shadow-sm'
18 } border-b border-[#E2E8F0]`}>
3419 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
35 <div className="flex justify-between items-center h-18">
20 <div className="flex justify-between items-center h-16 sm:h-18">
3621 {/* Logo */}
37 <div className="flex items-center">
38 <a href="/" className="flex flex-col leading-none py-4">
39 <div className="flex items-center gap-2.5">
40 <span className="text-lg sm:text-xl font-light text-gray-900 tracking-widest uppercase">
41 Hibiscus
42 </span>
43 <div className="w-6 h-px bg-gradient-to-r from-transparent via-[#D4AF37] to-transparent"></div>
44 <span className="text-lg sm:text-xl font-light text-gray-900 tracking-widest uppercase">
45 Airport
46 </span>
47 </div>
48 <div className="mt-0.5 text-center">
49 <span className="text-[10px] font-medium text-[#D4AF37] tracking-[0.25em] uppercase">
50 Premium Transport
51 </span>
52 </div>
53 </a>
54 </div>
22 <Link to="/" className="flex items-center gap-2">
23 <span className="text-base sm:text-lg font-light text-[#1E293B] tracking-[0.15em] uppercase">
24 Hibiscus
25 </span>
26 <div className="w-5 h-px bg-[#D4AF37]"></div>
27 <span className="text-base sm:text-lg font-light text-[#1E293B] tracking-[0.15em] uppercase">
28 Airport
29 </span>
30 </Link>
5531
56 {/* Desktop Navigation */}
57 <nav className="hidden md:flex items-center space-x-8" aria-label="Main navigation">
58 <button
59 onClick={() => scrollToSection('services')}
60 className="text-gray-600 hover:text-gray-900 transition-colors font-medium tracking-wide text-sm"
61 >
62 Services
63 </button>
64 <button
65 onClick={() => scrollToSection('fleet')}
66 className="text-gray-600 hover:text-gray-900 transition-colors font-medium tracking-wide text-sm"
67 >
68 Fleet
69 </button>
70 <button
71 onClick={() => scrollToSection('about')}
72 className="text-gray-600 hover:text-gray-900 transition-colors font-medium tracking-wide text-sm"
73 >
74 About
75 </button>
76 <button
77 onClick={() => scrollToSection('contact')}
78 className="text-gray-600 hover:text-gray-900 transition-colors font-medium tracking-wide text-sm"
79 >
80 Contact
81 </button>
82 <a
83 href="/booking"
84 className="inline-flex items-center bg-gray-900 hover:bg-gray-800 text-white px-6 py-2.5 text-sm font-semibold rounded-lg transition-all duration-200 shadow-sm hover:shadow-md"
32 {/* Desktop Nav */}
33 <nav className="hidden md:flex items-center gap-8" aria-label="Main navigation">
34 <Link to="/pricing" className="text-[#64748B] hover:text-[#1E293B] transition-colors text-sm font-medium">
35 Pricing
36 </Link>
37 <Link to="/service-areas" className="text-[#64748B] hover:text-[#1E293B] transition-colors text-sm font-medium">
38 Service Areas
39 </Link>
40 <Link to="/faq" className="text-[#64748B] hover:text-[#1E293B] transition-colors text-sm font-medium">
41 FAQ
42 </Link>
43 <Link to="/my-booking" className="text-[#64748B] hover:text-[#1E293B] transition-colors text-sm font-medium">
44 My Booking
45 </Link>
46 <Link
47 to="/booking"
48 className="bg-[#D4AF37] hover:bg-[#C4A030] text-white px-6 py-2.5 text-sm font-semibold rounded-lg shadow-sm hover:shadow-md transition-all duration-200"
8549 >
8650 Book Now
87 </a>
51 </Link>
8852 </nav>
8953
90 {/* Mobile Menu Button */}
91 <div className="flex items-center space-x-3 md:hidden">
92 <button
93 className="text-gray-700 hover:text-gray-900 transition-colors"
94 onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
95 aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
96 >
97 {mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
98 </button>
99 </div>
54 {/* Mobile toggle */}
55 <button
56 className="md:hidden text-[#64748B] hover:text-[#1E293B]"
57 onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
58 aria-label={mobileMenuOpen ? 'Close menu' : 'Open menu'}
59 >
60 {mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
61 </button>
10062 </div>
10163 </div>
10264
10365 {/* Mobile Menu */}
10466 {mobileMenuOpen && (
105 <div className="md:hidden bg-white border-t border-gray-100">
106 <div className="px-4 py-4 space-y-1">
107 <button
108 onClick={() => scrollToSection('services')}
109 className="block w-full text-left text-gray-700 hover:text-gray-900 hover:bg-gray-50 py-3 px-3 rounded-lg font-medium transition-colors"
110 >
111 Services
112 </button>
113 <button
114 onClick={() => scrollToSection('fleet')}
115 className="block w-full text-left text-gray-700 hover:text-gray-900 hover:bg-gray-50 py-3 px-3 rounded-lg font-medium transition-colors"
116 >
117 Fleet
118 </button>
119 <button
120 onClick={() => scrollToSection('about')}
121 className="block w-full text-left text-gray-700 hover:text-gray-900 hover:bg-gray-50 py-3 px-3 rounded-lg font-medium transition-colors"
122 >
123 About
124 </button>
125 <button
126 onClick={() => scrollToSection('contact')}
127 className="block w-full text-left text-gray-700 hover:text-gray-900 hover:bg-gray-50 py-3 px-3 rounded-lg font-medium transition-colors"
128 >
129 Contact
130 </button>
131 <div className="pt-3 border-t border-gray-100">
132 <a
133 href="/booking"
134 className="block w-full text-center bg-gray-900 hover:bg-gray-800 text-white py-3 px-6 rounded-lg font-semibold transition-colors"
67 <div className="md:hidden bg-white border-t border-[#E2E8F0]">
68 <div className="px-4 py-3 space-y-1">
69 {[
70 { to: '/pricing', label: 'Pricing' },
71 { to: '/service-areas', label: 'Service Areas' },
72 { to: '/faq', label: 'FAQ' },
73 { to: '/my-booking', label: 'My Booking' },
74 ].map(({ to, label }) => (
75 <Link
76 key={to}
77 to={to}
78 className="block py-3 px-3 rounded-lg text-[#1E293B] hover:bg-[#F8FAFC] font-medium"
79 onClick={() => setMobileMenuOpen(false)}
80 >
81 {label}
82 </Link>
83 ))}
84 <div className="pt-2 border-t border-[#E2E8F0]">
85 <Link
86 to="/booking"
87 className="block w-full text-center bg-[#D4AF37] hover:bg-[#C4A030] text-white py-3 px-6 rounded-lg font-semibold"
88 onClick={() => setMobileMenuOpen(false)}
13589 >
13690 Book Now
137 </a>
91 </Link>
13892 </div>
13993 </div>
14094 </div>
Modifiedfrontend/src/components/Hero.jsx+102−94View fileUnifiedSplit
11import React from 'react';
22import { Link } from 'react-router-dom';
3import { ArrowRight, Shield, Clock, Star } from 'lucide-react';
3import { ArrowRight, Star, Shield, Clock, Users, CheckCircle, MapPin } from 'lucide-react';
44
55const Hero = () => {
66 return (
7 <section className="relative pt-32 pb-20 sm:pt-36 sm:pb-28 bg-white overflow-hidden min-h-[90vh] flex items-center">
8 {/* Subtle decorative shape — top right */}
9 <div className="absolute top-0 right-0 w-[500px] h-[500px] bg-gradient-to-bl from-gray-50 via-gray-100/50 to-transparent rounded-full -translate-y-1/3 translate-x-1/4 pointer-events-none"></div>
10 {/* Subtle decorative shape — bottom left */}
11 <div className="absolute bottom-0 left-0 w-[400px] h-[400px] bg-gradient-to-tr from-gray-50 via-gray-100/30 to-transparent rounded-full translate-y-1/3 -translate-x-1/4 pointer-events-none"></div>
7 <section className="relative pt-28 pb-12 sm:pt-36 sm:pb-16 bg-white overflow-hidden">
8 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
129
13 <div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full">
14 <div className="text-center max-w-4xl mx-auto">
15 {/* Tagline */}
16 <p className="text-gray-400 text-sm font-medium tracking-[0.2em] uppercase mb-8">
17 24/7 Premium Service &bull; Fully Insured &bull; Instant Booking
18 </p>
10 {/* Two-column layout: Message left, Social proof right */}
11 <div className="lg:grid lg:grid-cols-2 lg:gap-16 items-center">
1912
20 {/* Heading */}
21 <h1
22 className="text-4xl sm:text-5xl lg:text-7xl font-bold text-gray-900 mb-5 leading-[1.1]"
23 style={{ fontFamily: 'Playfair Display, serif' }}
24 >
25 Premium Airport{' '}
26 <span className="text-[#D4AF37]">Transfers</span>
27 </h1>
28 {/* Subtle gold divider */}
29 <div className="w-20 h-[1px] bg-[#D4AF37]/20 mx-auto mb-5"></div>
30 <p className="text-2xl sm:text-3xl lg:text-4xl font-normal text-gray-500 tracking-wider mb-2" style={{ fontFamily: 'Playfair Display, serif', letterSpacing: '0.08em' }}>
31 Hibiscus Coast to Auckland Airport
32 </p>
13 {/* LEFT — Message + CTA */}
14 <div className="mb-12 lg:mb-0">
15 <p className="text-[#D4AF37] text-xs font-semibold tracking-[0.2em] uppercase mb-4">
16 Hibiscus Coast &rarr; Auckland Airport
17 </p>
3318
34 {/* Description */}
35 <p className="text-lg sm:text-xl text-gray-500 mb-12 max-w-2xl mx-auto leading-relaxed font-light">
36 Door-to-door private shuttle — no sharing, no waiting.
37 Professional drivers, comfortable vehicles, flat rates 24/7.
38 </p>
19 <h1 className="text-3xl sm:text-4xl lg:text-5xl font-bold text-[#1E293B] mb-5 leading-[1.15]"
20 style={{ fontFamily: 'Playfair Display, serif' }}>
21 Private Airport Transfers.
22 <br />
23 <span className="text-[#64748B] font-normal">From $100. Book in 60 seconds.</span>
24 </h1>
3925
40 {/* CTA buttons */}
41 <div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8">
42 <Link
43 to="/booking"
44 className="inline-flex items-center bg-gray-900 hover:bg-gray-800 text-white px-12 py-5 text-lg font-semibold rounded-xl shadow-lg hover:shadow-xl hover:translate-y-[-2px] transition-all duration-300"
45 >
46 Book Your Transfer
47 <ArrowRight className="ml-3 w-5 h-5" />
48 </Link>
49 </div>
26 <p className="text-[#64748B] text-base sm:text-lg mb-8 max-w-lg leading-relaxed">
27 Door-to-door from Orewa, Whangaparaoa, Silverdale and all Hibiscus Coast suburbs.
28 No sharing. No surge pricing. 24/7.
29 </p>
5030
51 {/* Trust signals */}
52 <div className="flex items-center justify-center gap-8 sm:gap-10 mb-24">
53 <div className="flex items-center gap-2 text-gray-400 text-sm">
54 <Shield className="w-4 h-4 text-[#D4AF37]" />
55 <span>Flat Rates</span>
56 </div>
57 <div className="flex items-center gap-2 text-gray-400 text-sm">
58 <Clock className="w-4 h-4 text-[#D4AF37]" />
59 <span>24/7 Service</span>
31 {/* CTA */}
32 <div className="flex flex-col sm:flex-row gap-3 mb-8">
33 <Link
34 to="/booking"
35 className="inline-flex items-center justify-center bg-[#D4AF37] hover:bg-[#C4A030] text-white px-8 py-4 text-base font-semibold rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
36 >
37 Book Your Transfer
38 <ArrowRight className="ml-2 w-5 h-5" />
39 </Link>
40 <Link
41 to="/pricing"
42 className="inline-flex items-center justify-center border border-[#E2E8F0] hover:border-[#D4AF37] text-[#1E293B] px-8 py-4 text-base font-medium rounded-lg hover:bg-[#FAFBFC] transition-all duration-200"
43 >
44 View Pricing
45 </Link>
6046 </div>
61 <div className="flex items-center gap-2 text-gray-400 text-sm">
62 <Star className="w-4 h-4 text-[#D4AF37]" />
63 <span>4.9&#9733; Rated</span>
64 </div>
65 </div>
66 </div>
6747
68 {/* Stats Section */}
69 <div className="grid grid-cols-2 md:grid-cols-4 gap-4 sm:gap-6 max-w-4xl mx-auto">
70 <div className="text-center p-6 sm:p-8 bg-white rounded-2xl border border-gray-200 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md hover:border-gray-300 transition-all duration-300">
71 <div
72 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-1"
73 style={{ fontFamily: 'Playfair Display, serif' }}
74 >
75 5000+
76 </div>
77 <div className="text-gray-400 font-medium tracking-wider text-xs uppercase">
78 Satisfied Clients
48 {/* Trust row */}
49 <div className="flex flex-wrap gap-x-5 gap-y-2">
50 {[
51 { icon: Shield, text: 'Fully insured' },
52 { icon: Clock, text: '24/7 service' },
53 { icon: Users, text: 'Private — never shared' },
54 ].map(({ icon: Icon, text }) => (
55 <div key={text} className="flex items-center gap-1.5 text-[#64748B] text-sm">
56 <Icon className="w-4 h-4 text-[#D4AF37]" />
57 <span>{text}</span>
58 </div>
59 ))}
7960 </div>
8061 </div>
81 <div className="text-center p-6 sm:p-8 bg-white rounded-2xl border border-gray-200 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md hover:border-gray-300 transition-all duration-300">
82 <div
83 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-1"
84 style={{ fontFamily: 'Playfair Display, serif' }}
85 >
86 60s
87 </div>
88 <div className="text-gray-400 font-medium tracking-wider text-xs uppercase">
89 Booking Time
62
63 {/* RIGHT — Social proof card */}
64 <div className="bg-[#F8FAFC] rounded-2xl border border-[#E2E8F0] p-8 sm:p-10">
65 {/* Rating */}
66 <div className="flex items-center gap-3 mb-6">
67 <div className="flex">
68 {[1,2,3,4,5].map(i => (
69 <Star key={i} className="w-5 h-5 fill-[#D4AF37] text-[#D4AF37]" />
70 ))}
71 </div>
72 <span className="text-[#1E293B] font-semibold">4.9 out of 5</span>
9073 </div>
91 </div>
92 <div className="text-center p-6 sm:p-8 bg-white rounded-2xl border border-gray-200 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md hover:border-gray-300 transition-all duration-300">
93 <div
94 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-1"
95 style={{ fontFamily: 'Playfair Display, serif' }}
96 >
97 100%
74
75 <p className="text-[#1E293B] text-lg font-medium mb-1" style={{ fontFamily: 'Playfair Display, serif' }}>
76 "Best shuttle service on the Coast"
77 </p>
78 <p className="text-[#64748B] text-sm mb-6 leading-relaxed">
79 "On time, professional, and the flat rate means no surprises.
80 We've used them for every flight since moving to Orewa.
81 Wouldn't go with anyone else."
82 </p>
83 <div className="flex items-center gap-3 mb-8 pb-8 border-b border-[#E2E8F0]">
84 <div className="w-10 h-10 rounded-full bg-[#D4AF37]/10 flex items-center justify-center text-[#D4AF37] font-bold text-sm">
85 SK
86 </div>
87 <div>
88 <div className="text-[#1E293B] font-medium text-sm">Sarah K.</div>
89 <div className="text-[#94A3B8] text-xs">Orewa — Verified Customer</div>
90 </div>
9891 </div>
99 <div className="text-gray-400 font-medium tracking-wider text-xs uppercase">
100 Fully Insured
92
93 {/* Stats */}
94 <div className="grid grid-cols-3 gap-4 text-center">
95 <div>
96 <div className="text-2xl font-bold text-[#1E293B]" style={{ fontFamily: 'Playfair Display, serif' }}>5,000+</div>
97 <div className="text-[#94A3B8] text-xs font-medium uppercase tracking-wide mt-0.5">Customers</div>
98 </div>
99 <div>
100 <div className="text-2xl font-bold text-[#1E293B]" style={{ fontFamily: 'Playfair Display, serif' }}>24/7</div>
101 <div className="text-[#94A3B8] text-xs font-medium uppercase tracking-wide mt-0.5">Service</div>
102 </div>
103 <div>
104 <div className="text-2xl font-bold text-[#1E293B]" style={{ fontFamily: 'Playfair Display, serif' }}>$100</div>
105 <div className="text-[#94A3B8] text-xs font-medium uppercase tracking-wide mt-0.5">From</div>
106 </div>
101107 </div>
102108 </div>
103 <div className="text-center p-6 sm:p-8 bg-white rounded-2xl border border-gray-200 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md hover:border-gray-300 transition-all duration-300">
104 <div
105 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-1"
106 style={{ fontFamily: 'Playfair Display, serif' }}
107 >
108 4.9&#9733;
109 </div>
110 <div className="text-gray-400 font-medium tracking-wider text-xs uppercase">
111 Rating
112 </div>
109 </div>
110
111 {/* Suburbs served — SEO + trust */}
112 <div className="mt-12 pt-8 border-t border-[#E2E8F0]">
113 <div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-[#94A3B8] text-xs sm:text-sm">
114 <MapPin className="w-3.5 h-3.5 text-[#D4AF37] mr-1" />
115 <span className="font-medium text-[#64748B]">Serving:</span>
116 {['Orewa', 'Whangaparaoa', 'Silverdale', 'Red Beach', 'Gulf Harbour', 'Stanmore Bay', 'Albany', 'Browns Bay', 'Millwater', 'Warkworth'].map((suburb, i) => (
117 <span key={suburb}>
118 {suburb}{i < 9 ? <span className="mx-1">·</span> : ''}
119 </span>
120 ))}
113121 </div>
114122 </div>
115123 </div>
Modifiedfrontend/src/components/HowItWorks.jsx+9−9View fileUnifiedSplit
77 <section className="py-20 bg-white reveal-on-scroll">
88 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
99 <div className="text-center mb-16">
10 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
11 How It <span className="text-gold">Works</span>
10 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
11 How It Works
1212 </h2>
13 <div className="w-16 h-0.5 bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
14 <p className="text-xl text-gray-600 max-w-2xl mx-auto leading-relaxed">
13 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
14 <p className="text-xl text-[#4A5568] max-w-2xl mx-auto leading-relaxed">
1515 Booking a ride is quick and easy
1616 </p>
1717 </div>
1919 <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8">
2020 {howItWorks.map((step, index) => (
2121 <div key={step.step} className="relative">
22 <Card className="bg-white border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 h-full">
22 <Card className="bg-[#FAFBFC] border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 h-full">
2323 <CardContent className="p-8">
24 <div className="w-14 h-14 bg-gold/10 rounded-full flex items-center justify-center mb-6 text-gold text-2xl border border-gold/20" style={{ fontFamily: 'Playfair Display, serif' }}>
24 <div className="w-14 h-14 bg-white rounded-full flex items-center justify-center mb-6 text-[#1B2B4B] text-2xl border border-[#1B2B4B]/20" style={{ fontFamily: 'Playfair Display, serif' }}>
2525 {step.step}
2626 </div>
27 <h3 className="text-xl font-bold text-gray-900 mb-3">{step.title}</h3>
28 <p className="text-gray-600 leading-relaxed">{step.description}</p>
27 <h3 className="text-xl font-bold text-[#1B2B4B] mb-3">{step.title}</h3>
28 <p className="text-[#4A5568] leading-relaxed">{step.description}</p>
2929 </CardContent>
3030 </Card>
3131 {index < howItWorks.length - 1 && (
3232 <div className="hidden lg:block absolute top-1/2 -right-4 transform -translate-y-1/2 z-10">
33 <div className="w-8 h-0.5 bg-gray-200"></div>
33 <div className="w-8 h-0.5 bg-[#E8ECF0]"></div>
3434 </div>
3535 )}
3636 </div>
Modifiedfrontend/src/components/Services.jsx+11−11View fileUnifiedSplit
1717 <section id="services" className="py-20 bg-white reveal-on-scroll">
1818 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
1919 <div className="text-center mb-16">
20 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
21 Our <span className="text-gold">Services</span>
20 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
21 Our Services
2222 </h2>
23 <div className="w-16 h-0.5 bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
24 <p className="text-xl text-gray-600 max-w-3xl mx-auto leading-relaxed">
23 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
24 <p className="text-xl text-[#4A5568] max-w-3xl mx-auto leading-relaxed">
2525 Professional airport transportation solutions for every need
2626 </p>
2727 </div>
3030 {services.map((service) => {
3131 const IconComponent = iconMap[service.icon];
3232 return (
33 <Card key={service.id} className="bg-white border border-gray-100 border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 group">
33 <Card key={service.id} className="bg-[#FAFBFC] border border-[#E8ECF0] border-t-2 border-t-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 group">
3434 <CardHeader className="p-8">
35 <div className="w-14 h-14 bg-gold/10 rounded-full flex items-center justify-center mb-4">
36 <IconComponent className="w-7 h-7 text-gold" />
35 <div className="w-14 h-14 bg-[#1B2B4B]/5 rounded-full flex items-center justify-center mb-4">
36 <IconComponent className="w-7 h-7 text-[#1B2B4B]" />
3737 </div>
38 <CardTitle className="text-lg font-bold text-gray-900 mb-2">
38 <CardTitle className="text-lg font-bold text-[#1B2B4B] mb-2">
3939 {service.title}
4040 </CardTitle>
41 <CardDescription className="text-gray-600 text-sm">
41 <CardDescription className="text-[#4A5568] text-sm">
4242 {service.description}
4343 </CardDescription>
4444 </CardHeader>
4646 <ul className="space-y-3">
4747 {service.features.map((feature, idx) => (
4848 <li key={idx} className="flex items-start">
49 <Check className="w-5 h-5 text-gold mr-2 flex-shrink-0 mt-0.5" />
50 <span className="text-gray-600 text-sm">{feature}</span>
49 <Check className="w-5 h-5 text-[#1B2B4B] mr-2 flex-shrink-0 mt-0.5" />
50 <span className="text-[#4A5568] text-sm">{feature}</span>
5151 </li>
5252 ))}
5353 </ul>
Modifiedfrontend/src/components/Testimonials.jsx+12−12View fileUnifiedSplit
2222 ];
2323
2424 const TestimonialCard = ({ testimonial }) => (
25 <Card className="bg-white border border-gray-100 border-l-2 border-l-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 h-full">
25 <Card className="bg-[#FAFBFC] border border-[#E8ECF0] border-l-2 border-l-[#D4AF37] shadow-sm hover:shadow-md transition-all duration-300 h-full">
2626 <CardContent className="p-8">
27 <span className="text-5xl leading-none text-[#D4AF37]/30 font-serif select-none" style={{ fontFamily: 'Playfair Display, serif' }}>"</span>
27 <span className="text-5xl leading-none text-[#1B2B4B]/10 font-serif select-none" style={{ fontFamily: 'Playfair Display, serif' }}>"</span>
2828 <div className="flex mb-4">
2929 {[...Array(testimonial.rating)].map((_, i) => (
3030 <Star key={i} className="w-5 h-5 text-gold fill-current" />
3131 ))}
3232 </div>
33 <p className="text-gray-600 mb-6 italic leading-relaxed">"{testimonial.content}"</p>
33 <p className="text-[#4A5568] mb-6 italic leading-relaxed">"{testimonial.content}"</p>
3434 <div className="flex items-center">
3535 <img
3636 src={testimonial.avatar}
3939 className="w-12 h-12 rounded-full object-cover mr-4"
4040 />
4141 <div>
42 <div className="font-bold text-gray-900">{testimonial.name}</div>
43 <div className="text-sm text-gray-500">{testimonial.role}</div>
42 <div className="font-bold text-[#1B2B4B]">{testimonial.name}</div>
43 <div className="text-sm text-[#8896A6]">{testimonial.role}</div>
4444 </div>
4545 </div>
4646 </CardContent>
5151 <section className="py-20 bg-white reveal-on-scroll">
5252 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
5353 <div className="text-center mb-16">
54 <h2 className="text-4xl font-bold text-gray-900 mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
55 What Our <span className="text-gold">Customers</span> Say
54 <h2 className="text-4xl font-bold text-[#1B2B4B] mb-4" style={{ fontFamily: 'Playfair Display, serif' }}>
55 What Our Customers Say
5656 </h2>
57 <div className="w-16 h-0.5 bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
58 <p className="text-xl text-gray-600 max-w-2xl mx-auto leading-relaxed">
57 <div className="w-16 h-px bg-[#D4AF37] mx-auto mt-4 mb-6"></div>
58 <p className="text-xl text-[#4A5568] max-w-2xl mx-auto leading-relaxed">
5959 Don't just take our word for it - hear from our satisfied customers
6060 </p>
6161 </div>
7979 variant="outline"
8080 size="icon"
8181 onClick={prevTestimonial}
82 className="w-12 h-12 rounded-full border border-gray-200 hover:bg-gray-50 text-gray-600 hover:text-gray-900"
82 className="w-12 h-12 rounded-full border border-[#E8ECF0] hover:bg-[#F8FAFB] text-[#4A5568] hover:text-[#1B2B4B]"
8383 >
8484 <ChevronLeft className="w-5 h-5" />
8585 </Button>
8787 variant="outline"
8888 size="icon"
8989 onClick={nextTestimonial}
90 className="w-12 h-12 rounded-full border border-gray-200 hover:bg-gray-50 text-gray-600 hover:text-gray-900"
90 className="w-12 h-12 rounded-full border border-[#E8ECF0] hover:bg-[#F8FAFB] text-[#4A5568] hover:text-[#1B2B4B]"
9191 >
9292 <ChevronRight className="w-5 h-5" />
9393 </Button>
100100 key={idx}
101101 onClick={() => setCurrentIndex(idx)}
102102 className={`w-2 h-2 rounded-full transition-all duration-300 ${
103 idx === currentIndex ? 'bg-gold w-8' : 'bg-gray-300'
103 idx === currentIndex ? 'bg-[#D4AF37] w-8' : 'bg-[#E8ECF0]'
104104 }`}
105105 />
106106 ))}
Addedfrontend/src/hooks/useGoogleMaps.js+93−0View fileUnifiedSplit
1import { useState, useEffect } from 'react';
2
3/**
4 * Load the Google Maps JavaScript API via a plain <script> tag.
5 * Returns { isLoaded, loadError }.
6 *
7 * This replaces @react-google-maps/api's useLoadScript which can
8 * conflict with Radix UI portals. Loading via <script> gives us
9 * full control — Google's Autocomplete dropdown renders in the
10 * real DOM, completely outside React.
11 */
12let _loadPromise = null;
13let _loaded = false;
14let _error = null;
15
16function loadGoogleMaps(apiKey) {
17 if (_loadPromise) return _loadPromise;
18 if (window.google?.maps?.places) {
19 _loaded = true;
20 return Promise.resolve();
21 }
22
23 _loadPromise = new Promise((resolve, reject) => {
24 if (!apiKey) {
25 _error = new Error('No Google Maps API key');
26 reject(_error);
27 return;
28 }
29
30 const script = document.createElement('script');
31 script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
32 script.async = true;
33 script.defer = true;
34 script.onload = () => {
35 _loaded = true;
36 resolve();
37 };
38 script.onerror = () => {
39 _error = new Error('Failed to load Google Maps');
40 reject(_error);
41 };
42 document.head.appendChild(script);
43 });
44
45 return _loadPromise;
46}
47
48export function useGoogleMaps(apiKey) {
49 const [isLoaded, setIsLoaded] = useState(_loaded);
50 const [loadError, setLoadError] = useState(_error);
51
52 useEffect(() => {
53 if (_loaded) {
54 setIsLoaded(true);
55 return;
56 }
57 if (!apiKey) {
58 setLoadError(new Error('No API key'));
59 return;
60 }
61
62 loadGoogleMaps(apiKey)
63 .then(() => setIsLoaded(true))
64 .catch((err) => setLoadError(err));
65 }, [apiKey]);
66
67 return { isLoaded, loadError };
68}
69
70/**
71 * Attach Google Places Autocomplete to an input ref.
72 * Call this after isLoaded is true.
73 */
74export function attachAutocomplete(inputRef, acRef, onPlaceChanged) {
75 if (!inputRef.current || acRef.current) return;
76 if (!window.google?.maps?.places) return;
77
78 const ac = new window.google.maps.places.Autocomplete(inputRef.current, {
79 componentRestrictions: { country: 'nz' },
80 fields: ['formatted_address', 'name'],
81 });
82
83 acRef.current = ac;
84
85 ac.addListener('place_changed', () => {
86 const place = ac.getPlace();
87 const address = place.formatted_address || place.name || '';
88 if (inputRef.current) inputRef.current.value = address;
89 onPlaceChanged(address);
90 });
91
92 return ac;
93}
Modifiedfrontend/src/pages/AdminCreateBooking.jsx+2−7View fileUnifiedSplit
77import { ArrowLeft, Calendar, Clock, Users, MapPin, DollarSign, Loader2 } from 'lucide-react';
88import { useToast } from '../hooks/use-toast';
99import axios from 'axios';
10import { useLoadScript } from '@react-google-maps/api';
10import { useGoogleMaps, attachAutocomplete } from '../hooks/useGoogleMaps';
1111
1212import { BACKEND_URL, GOOGLE_MAPS_API_KEY } from '../config';
1313
14const libraries = ['places'];
15
1614const AdminCreateBooking = () => {
1715 const navigate = useNavigate();
1816 const { toast } = useToast();
2725 const additionalInputRefs = useRef({});
2826 const additionalACRefs = useRef({});
2927
30 const { isLoaded } = useLoadScript({
31 googleMapsApiKey: GOOGLE_MAPS_API_KEY,
32 libraries,
33 });
28 const { isLoaded } = useGoogleMaps(GOOGLE_MAPS_API_KEY);
3429
3530 const [formData, setFormData] = useState({
3631 name: '',
Modifiedfrontend/src/pages/AdminDashboard.jsx+8−8View fileUnifiedSplit
7979 headers: { Authorization: `Bearer ${token}` }
8080 });
8181 setCalendarAuthorized(response.data.authorized);
82 } catch (error) {
83 console.error('Failed to check calendar status:', error);
82 } catch {
83 // Non-critical — calendar status check failed silently
8484 }
8585 };
8686 checkCalendarStatus();
151151 headers: { Authorization: `Bearer ${token}` }
152152 });
153153 setPromoCodes(response.data);
154 } catch (error) {
155 console.error('Error fetching promo codes:', error);
154 } catch {
155 // Promo codes fetch failed — non-critical
156156 }
157157 };
158158
209209 headers: { Authorization: `Bearer ${token}` }
210210 });
211211 setDeletedBookings(response.data);
212 } catch (error) {
213 console.error('Error fetching deleted bookings:', error);
212 } catch {
213 // Deleted bookings fetch failed — non-critical
214214 }
215215 };
216216
257257 }
258258 });
259259 setDrivers(response.data);
260 } catch (error) {
261 console.log('Drivers not loaded:', error.message);
260 } catch {
261 // Drivers fetch failed — non-critical
262262 }
263263 };
264264
Modifiedfrontend/src/pages/AdminEditBooking.jsx+2−7View fileUnifiedSplit
77import { ArrowLeft, User, MapPin, Calendar, DollarSign, Save, Loader2, Plus, X } from 'lucide-react';
88import { useToast } from '../hooks/use-toast';
99import axios from 'axios';
10import { useLoadScript } from '@react-google-maps/api';
10import { useGoogleMaps, attachAutocomplete } from '../hooks/useGoogleMaps';
1111
1212import { BACKEND_URL, GOOGLE_MAPS_API_KEY } from '../config';
1313
14const libraries = ['places'];
15
1614const AdminEditBooking = () => {
1715 const navigate = useNavigate();
1816 const { bookingId } = useParams();
2826 const additionalInputRefs = useRef({});
2927 const additionalACRefs = useRef({});
3028
31 const { isLoaded } = useLoadScript({
32 googleMapsApiKey: GOOGLE_MAPS_API_KEY,
33 libraries,
34 });
29 const { isLoaded } = useGoogleMaps(GOOGLE_MAPS_API_KEY);
3530
3631 const [formData, setFormData] = useState({
3732 name: '',
Modifiedfrontend/src/pages/BookingPage.jsx+43−74View fileUnifiedSplit
88import { useToast } from '../hooks/use-toast';
99import axios from 'axios';
1010
11// Constants
12const VIP_PICKUP_FEE = 15;
13const OVERSIZED_LUGGAGE_FEE = 25;
14const API_TIMEOUT_MS = 15000;
15const PRICE_DEBOUNCE_MS = 500;
16
1117// Safety: prevent hung requests (no UI change)
12axios.defaults.timeout = 15000;
18axios.defaults.timeout = API_TIMEOUT_MS;
1319
14import { useLoadScript } from '@react-google-maps/api';
20import { useGoogleMaps, attachAutocomplete } from '../hooks/useGoogleMaps';
1521import PageMeta from '../components/PageMeta';
1622
1723import { BACKEND_URL, GOOGLE_MAPS_API_KEY } from '../config';
1824
19const libraries = ['places'];
20
2125// Compact Date Picker Modal - Clean iOS-style
2226const DatePickerModal = ({ isOpen, onClose, onSelect, selectedDate }) => {
2327 const [currentMonth, setCurrentMonth] = useState(new Date());
179183
180184const BookingPage = () => {
181185 const { toast } = useToast();
182 const [isPending, startTransition] = React.useTransition();
186 const [, startTransition] = React.useTransition();
183187 const [calculating, setCalculating] = useState(false);
184188 const [pricing, setPricing] = useState(null);
185189 const pickupInputRef = useRef(null);
228232 const [promoDiscount, setPromoDiscount] = useState(null);
229233 const [applyingPromo, setApplyingPromo] = useState(false);
230234
231 const { isLoaded, loadError } = useLoadScript({
232 googleMapsApiKey: GOOGLE_MAPS_API_KEY,
233 libraries,
234 });
235
236 // If Maps fails to load (bad key, network, etc.) fall back to plain inputs
235 const { isLoaded, loadError } = useGoogleMaps(GOOGLE_MAPS_API_KEY);
237236 const mapsAvailable = isLoaded && !loadError;
238237
239238 const [formData, setFormData] = useState({
285284 ...formData,
286285 [name]: type === 'checkbox' ? checked : value
287286 });
288
289 if ((name === 'pickupAddress' || name === 'dropoffAddress' || name === 'passengers') &&
290 formData.pickupAddress && formData.dropoffAddress) {
291 setTimeout(() => {
292 calculatePrice();
293 }, 500);
294 }
295287 };
296288
289 // Debounced price recalculation when address or passenger fields change
290 useEffect(() => {
291 if (formData.pickupAddress && formData.dropoffAddress) {
292 const timer = setTimeout(() => {
293 calculatePriceWithAddresses(formData.pickupAddress, formData.dropoffAddress);
294 }, PRICE_DEBOUNCE_MS);
295 return () => clearTimeout(timer);
296 }
297 }, [formData.pickupAddress, formData.dropoffAddress, formData.passengers]);
298
297299 const addPickupLocation = () => {
298300 setFormData({
299301 ...formData,
359361 setPromoDiscount(null);
360362 };
361363
362 // Attach Google's native Autocomplete to plain <input> elements.
363 // Google renders its own .pac-container dropdown in the real DOM,
364 // completely outside React — no Radix/portal conflicts.
364 // Attach Google Places Autocomplete when API is loaded
365365 useEffect(() => {
366 if (!mapsAvailable || !window.google) return;
367
368 const acOptions = {
369 componentRestrictions: { country: 'nz' },
370 fields: ['formatted_address', 'name'],
371 };
366 if (!mapsAvailable) return;
372367
373 if (pickupInputRef.current && !pickupACRef.current) {
374 const ac = new window.google.maps.places.Autocomplete(pickupInputRef.current, acOptions);
375 pickupACRef.current = ac;
376 ac.addListener('place_changed', () => {
377 const place = ac.getPlace();
378 const address = place.formatted_address || place.name || '';
379 setFormData(prev => {
380 const updated = { ...prev, pickupAddress: address };
381 if (prev.dropoffAddress) {
382 setTimeout(() => calculatePriceWithAddresses(address, prev.dropoffAddress), 300);
383 }
384 return updated;
385 });
386 if (pickupInputRef.current) pickupInputRef.current.value = address;
387 });
388 }
368 attachAutocomplete(pickupInputRef, pickupACRef, (address) => {
369 setFormData(prev => ({ ...prev, pickupAddress: address }));
370 });
389371
390 if (dropoffInputRef.current && !dropoffACRef.current) {
391 const ac = new window.google.maps.places.Autocomplete(dropoffInputRef.current, acOptions);
392 dropoffACRef.current = ac;
393 ac.addListener('place_changed', () => {
394 const place = ac.getPlace();
395 const address = place.formatted_address || place.name || '';
396 setFormData(prev => {
397 const updated = { ...prev, dropoffAddress: address };
398 if (prev.pickupAddress) {
399 setTimeout(() => calculatePriceWithAddresses(prev.pickupAddress, address), 300);
400 }
401 return updated;
402 });
403 if (dropoffInputRef.current) dropoffInputRef.current.value = address;
404 });
405 }
372 attachAutocomplete(dropoffInputRef, dropoffACRef, (address) => {
373 setFormData(prev => ({ ...prev, dropoffAddress: address }));
374 });
406375 }, [mapsAvailable]);
407376
408377 const calculatePriceWithAddresses = async (pickup, dropoff) => {
409378 if (!pickup || !dropoff) return;
410
379
411380 setCalculating(true);
412381 try {
413382 const response = await axios.post(`${BACKEND_URL}/api/calculate-price`, {
415384 dropoffAddress: dropoff,
416385 passengers: parseInt(formData.passengers)
417386 });
418
387
419388 let additionalFees = 0;
420 if (formData.vipPickup) additionalFees += 15;
421 if (formData.oversizedLuggage) additionalFees += 25;
422
389 if (formData.vipPickup) additionalFees += VIP_PICKUP_FEE;
390 if (formData.oversizedLuggage) additionalFees += OVERSIZED_LUGGAGE_FEE;
391
423392 let totalPrice = response.data.totalPrice + additionalFees;
424393 if (formData.returnTrip) totalPrice *= 2;
425
394
426395 startTransition(() => {
427396 setPricing({
428397 ...response.data,
431400 totalPrice: totalPrice
432401 });
433402 });
434 } catch (error) {
435 console.error('Price calculation error:', error);
403 } catch {
404 // Error is non-critical here; user can retry via Calculate Price button
436405 } finally {
437406 setCalculating(false);
438407 }
452421 });
453422
454423 let additionalFees = 0;
455 if (formData.vipPickup) additionalFees += 15;
456 if (formData.oversizedLuggage) additionalFees += 25;
424 if (formData.vipPickup) additionalFees += VIP_PICKUP_FEE;
425 if (formData.oversizedLuggage) additionalFees += OVERSIZED_LUGGAGE_FEE;
457426
458427 let totalPrice = response.data.totalPrice + additionalFees;
459428 if (formData.returnTrip) totalPrice *= 2;
466435 totalPrice: totalPrice
467436 });
468437 });
469 } catch (error) {
438 } catch (err) {
470439 toast({
471440 title: "Calculation Error",
472 description: error.response?.data?.detail || "Could not calculate distance. Please check addresses.",
441 description: err.response?.data?.detail || "Could not calculate distance. Please check addresses.",
473442 variant: "destructive"
474443 });
475444 } finally {
857826 <p className="text-xs text-gray-500">Driver meets you inside the terminal with a name sign</p>
858827 </div>
859828 </div>
860 <span className="font-semibold text-gold">+$15</span>
829 <span className="font-semibold text-gold">+${VIP_PICKUP_FEE}</span>
861830 </label>
862
831
863832 <label className="flex items-center justify-between p-3 rounded-md border border-gray-200 hover:border-gold cursor-pointer transition-colors">
864833 <div className="flex items-center gap-3">
865834 <input
874843 <p className="text-xs text-gray-500">Golf clubs, surfboards, bikes, etc.</p>
875844 </div>
876845 </div>
877 <span className="font-semibold text-gold">+$25</span>
846 <span className="font-semibold text-gold">+${OVERSIZED_LUGGAGE_FEE}</span>
878847 </label>
879848
880849 <label className="flex items-center justify-between p-3 rounded-md border border-gray-200 hover:border-gold cursor-pointer transition-colors">
Modifiedfrontend/src/pages/CustomerTracking.jsx+0−1View fileUnifiedSplit
5454 setTrackingData(response.data);
5555 setError(null);
5656 } catch (err) {
57 console.error('Error fetching tracking data:', err);
5857 if (err.response?.status === 404) {
5958 setError('Tracking not found. The driver may not have started yet.');
6059 } else {
Modifiedfrontend/src/pages/DriverPortal.jsx+4−7View fileUnifiedSplit
103103 setTodaysBookings(relevantBookings);
104104
105105 } catch (error) {
106 console.error('Error fetching data:', error);
107106 toast({
108107 title: 'Error',
109108 description: 'Failed to load bookings',
152151 startLocationWatch(booking);
153152
154153 } catch (error) {
155 console.error('Error starting tracking:', error);
156154 toast({
157155 title: 'Error',
158156 description: 'Failed to start tracking',
170168 setLocationError(null);
171169 },
172170 (error) => {
173 console.error('Location error:', error);
174171 setLocationError('Unable to get your location. Please enable GPS.');
175172 },
176173 {
229226 });
230227 }
231228
232 } catch (error) {
233 console.error('Error updating location:', error);
229 } catch {
230 // Location update failed — will retry on next interval
234231 }
235232 };
236233
264261 description: 'Tracking stopped. Job complete!',
265262 });
266263
267 } catch (error) {
268 console.error('Error stopping tracking:', error);
264 } catch {
265 // Stop tracking failed — non-critical
269266 }
270267 };
271268
Modifiedfrontend/src/pages/DriverTracking.jsx+8−8View fileUnifiedSplit
5959 return;
6060 } catch (trackingError) {
6161 // If tracking not found, try with auth token
62 console.log('Tracking endpoint not available, trying authenticated endpoint');
62 // Tracking endpoint not available, try authenticated endpoint
6363 }
6464
6565 // Fallback to authenticated endpoint
8181 setDriver(driverRes.data);
8282 }
8383 } catch (error) {
84 console.error('Error fetching booking:', error);
84 // Booking fetch failed
8585 } finally {
8686 setLoading(false);
8787 }
112112 startContinuousTracking();
113113
114114 } catch (error) {
115 console.error('Location permission error:', error);
115 // Location permission error
116116 if (error.code === 1) {
117117 setLocationError('Location permission denied. Please enable GPS.');
118118 } else {
132132 setLocationError(null);
133133 },
134134 (error) => {
135 console.error('Location watch error:', error);
135 // GPS signal lost
136136 setLocationError('GPS signal lost. Please ensure GPS is enabled.');
137137 },
138138 {
167167 if (response.data.sms_sent && !smsSent) {
168168 setSmsSent(true);
169169 }
170 } catch (error) {
171 console.error('Error sending location update:', error);
170 } catch {
171 // Location update failed — will retry on next interval
172172 }
173173 };
174174
196196 await axios.post(`${BACKEND_URL}/api/tracking/stop/${bookingId}`);
197197 stopTracking();
198198 alert('✅ Trip marked as arrived!');
199 } catch (error) {
200 console.error('Error stopping tracking:', error);
199 } catch {
200 // Stop tracking failed — non-critical
201201 }
202202 };
203203
Modifiedfrontend/src/pages/HomePage.jsx+67−10View fileUnifiedSplit
11import React from 'react';
2import { Link } from 'react-router-dom';
23import Header from '../components/Header';
34import Hero from '../components/Hero';
4import Services from '../components/Services';
5import Fleet from '../components/Fleet';
6import About from '../components/About';
7import Contact from '../components/Contact';
85import Footer from '../components/Footer';
96import PageMeta from '../components/PageMeta';
10import GeoContent from '../components/GeoContent';
7import { ArrowRight, Car, CreditCard, CheckCircle } from 'lucide-react';
118
129const HomePage = () => {
1310 return (
2017 />
2118 <Header />
2219 <Hero />
23 <Services />
24 <Fleet />
25 <About />
26 <GeoContent />
27 <Contact />
20
21 {/* HOW IT WORKS — 3 steps, nothing more */}
22 <section className="py-16 sm:py-20 bg-[#F8FAFC]">
23 <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
24 <h2 className="text-2xl sm:text-3xl font-bold text-[#1E293B] text-center mb-3"
25 style={{ fontFamily: 'Playfair Display, serif' }}>
26 Book in 3 Simple Steps
27 </h2>
28 <div className="w-12 h-0.5 bg-[#D4AF37] mx-auto mb-12"></div>
29
30 <div className="grid md:grid-cols-3 gap-8 sm:gap-10">
31 {[
32 {
33 step: '1',
34 icon: Car,
35 title: 'Enter Your Address',
36 desc: 'Tell us where to pick you up and when you need to be at the airport.'
37 },
38 {
39 step: '2',
40 icon: CreditCard,
41 title: 'Get Your Price & Pay',
42 desc: 'Instant flat-rate quote. Pay online or choose cash — no surge, no hidden fees.'
43 },
44 {
45 step: '3',
46 icon: CheckCircle,
47 title: 'We Pick You Up',
48 desc: 'Professional driver at your door. Private ride, flight monitoring, door-to-door.'
49 }
50 ].map(({ step, icon: Icon, title, desc }) => (
51 <div key={step} className="text-center">
52 <div className="w-14 h-14 rounded-full bg-white border-2 border-[#D4AF37] flex items-center justify-center mx-auto mb-5 text-[#D4AF37] font-bold text-lg shadow-sm"
53 style={{ fontFamily: 'Playfair Display, serif' }}>
54 {step}
55 </div>
56 <h3 className="text-lg font-semibold text-[#1E293B] mb-2">{title}</h3>
57 <p className="text-[#64748B] text-sm leading-relaxed max-w-xs mx-auto">{desc}</p>
58 </div>
59 ))}
60 </div>
61 </div>
62 </section>
63
64 {/* FINAL CTA — one clear action */}
65 <section className="py-16 sm:py-20 bg-white">
66 <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
67 <h2 className="text-2xl sm:text-3xl font-bold text-[#1E293B] mb-4"
68 style={{ fontFamily: 'Playfair Display, serif' }}>
69 Ready to Book Your Airport Transfer?
70 </h2>
71 <p className="text-[#64748B] text-base sm:text-lg mb-8 max-w-xl mx-auto">
72 From $100. Private ride. Professional driver. Flat rates 24/7.
73 Book online in under 60 seconds.
74 </p>
75 <Link
76 to="/booking"
77 className="inline-flex items-center bg-[#D4AF37] hover:bg-[#C4A030] text-white px-10 py-4 text-lg font-semibold rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
78 >
79 Book Now
80 <ArrowRight className="ml-2 w-5 h-5" />
81 </Link>
82 </div>
83 </section>
84
2885 <Footer />
2986 </div>
3087 );
Modifiedfrontend/src/pages/MyBooking.jsx+16−10View fileUnifiedSplit
6565 setError('Something went wrong. Please try again later.');
6666 }
6767 } catch (err) {
68 console.error('Lookup error:', err);
6968 setError('Could not connect to the server. Please try again.');
7069 } finally {
7170 setLoading(false);
7372 };
7473
7574 // Auto-search if ref was passed via URL
76 const initialRefValue = initialRef;
7775 React.useEffect(() => {
78 if (initialRefValue) {
79 setRef(initialRefValue);
76 if (initialRef) {
77 setRefInput(initialRef);
8078 const doLookup = async () => {
8179 setLoading(true);
82 setError('');
80 setError(null);
81 setSearched(true);
8382 try {
84 const res = await axios.get(`${BACKEND_URL}/api/bookings/lookup/${initialRefValue}`);
85 setBooking(res.data);
86 } catch (err) {
87 setError(err.response?.status === 404 ? 'Booking not found. Please check your reference.' : 'Unable to look up booking. Please try again.');
83 const response = await fetch(`${BACKEND_URL}/api/bookings/lookup/${initialRef.trim().toUpperCase()}`);
84 if (response.ok) {
85 const data = await response.json();
86 setBooking(data);
87 } else if (response.status === 404) {
88 setError('Booking not found. Please check your reference.');
89 } else {
90 setError('Unable to look up booking. Please try again.');
91 }
92 } catch {
93 setError('Could not connect to the server. Please try again.');
8894 } finally {
8995 setLoading(false);
9096 }
9197 };
9298 doLookup();
9399 }
94 }, [initialRefValue]);
100 }, [initialRef]);
95101
96102 return (
97103 <div className="min-h-screen bg-black">
Modifiedfrontend/src/pages/PaymentSuccess.jsx+18−10View fileUnifiedSplit
1919 const isCash = method === 'cash';
2020
2121 useEffect(() => {
22 const fetchBooking = async () => {
22 // Poll for booking data (webhook may take a moment to process)
23 const pollForBooking = async (attempts = 0) => {
2324 try {
2425 let response;
2526 if (bookingId) {
3334 if (response && response.ok) {
3435 const data = await response.json();
3536 setBooking(data);
37 setLoading(false);
38 } else if (attempts < 3) {
39 setTimeout(() => pollForBooking(attempts + 1), 1500);
3640 } else {
37 setError('Could not load booking details.');
41 setLoading(false);
42 setError('Could not load booking details. Your booking was confirmed — check your email.');
43 }
44 } catch {
45 if (attempts < 3) {
46 setTimeout(() => pollForBooking(attempts + 1), 1500);
47 } else {
48 setLoading(false);
49 setError('Could not load booking details. Your booking was confirmed — check your email.');
3850 }
39 } catch (err) {
40 console.error('Failed to fetch booking:', err);
41 setError('Could not load booking details.');
42 } finally {
43 setLoading(false);
4451 }
4552 };
4653
47 // Small delay for Stripe webhooks to process payment status
48 const delay = isCash ? 500 : 2500;
49 setTimeout(fetchBooking, delay);
54 // Cash bookings are instant; Stripe needs a moment for the webhook
55 const initialDelay = isCash ? 500 : 1500;
56 const timeoutId = setTimeout(() => pollForBooking(0), initialDelay);
57 return () => clearTimeout(timeoutId);
5058 }, [sessionId, bookingId, isCash]);
5159
5260 if (loading) {
Modifiedfrontend/yarn.lock+6−53View fileUnifiedSplit
13571357 resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f"
13581358 integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==
13591359
1360"@googlemaps/js-api-loader@1.16.8":
1361 version "1.16.8"
1362 resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz#1595a2af80ca07e551fc961d921a2437d1cb3643"
1363 integrity sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==
1364
1365"@googlemaps/markerclusterer@2.5.3":
1366 version "2.5.3"
1367 resolved "https://registry.yarnpkg.com/@googlemaps/markerclusterer/-/markerclusterer-2.5.3.tgz#9f891ce7e8e161775f3a3e2c9f66956810284591"
1368 integrity sha512-x7lX0R5yYOoiNectr10wLgCBasNcXFHiADIBdmn7jQllF2B5ENQw5XtZK+hIw4xnV0Df0xhN4LN98XqA5jaiOw==
1369 dependencies:
1370 fast-deep-equal "^3.1.3"
1371 supercluster "^8.0.1"
1372
13731360"@hookform/resolvers@^5.0.1":
13741361 version "5.2.2"
13751362 resolved "https://registry.yarnpkg.com/@hookform/resolvers/-/resolvers-5.2.2.tgz#5ac16cd89501ca31671e6e9f0f5c5d762a99aa12"
23562343 resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb"
23572344 integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==
23582345
2359"@react-google-maps/api@^2.20.7":
2360 version "2.20.8"
2361 resolved "https://registry.yarnpkg.com/@react-google-maps/api/-/api-2.20.8.tgz#33b16df2a437065722bd458284f8bfe9ba74e939"
2362 integrity sha512-wtLYFtCGXK3qbIz1H5to3JxbosPnKsvjDKhqGylXUb859EskhzR7OpuNt0LqdLarXUtZCJTKzPn3BNaekNIahg==
2363 dependencies:
2364 "@googlemaps/js-api-loader" "1.16.8"
2365 "@googlemaps/markerclusterer" "2.5.3"
2366 "@react-google-maps/infobox" "2.20.0"
2367 "@react-google-maps/marker-clusterer" "2.20.0"
2368 "@types/google.maps" "3.58.1"
2369 invariant "2.2.4"
2370
2371"@react-google-maps/infobox@2.20.0":
2372 version "2.20.0"
2373 resolved "https://registry.yarnpkg.com/@react-google-maps/infobox/-/infobox-2.20.0.tgz#7c3dd1821c9f1e1e92570f37419b97f6f956c7ee"
2374 integrity sha512-03PJHjohhaVLkX6+NHhlr8CIlvUxWaXhryqDjyaZ8iIqqix/nV8GFdz9O3m5OsjtxtNho09F/15j14yV0nuyLQ==
2375
2376"@react-google-maps/marker-clusterer@2.20.0":
2377 version "2.20.0"
2378 resolved "https://registry.yarnpkg.com/@react-google-maps/marker-clusterer/-/marker-clusterer-2.20.0.tgz#6b64177843a60c66e0ebaf85037a47ecd07007df"
2379 integrity sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==
2380
23812346"@rollup/plugin-babel@^5.2.0":
23822347 version "5.3.1"
23832348 resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"
27282693 "@types/qs" "*"
27292694 "@types/serve-static" "^1"
27302695
2731"@types/google.maps@3.58.1":
2732 version "3.58.1"
2733 resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.58.1.tgz#71ce3dec44de1452f56641d2c87c7dd8ea964b4d"
2734 integrity sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==
2735
27362696"@types/graceful-fs@^4.1.2":
27372697 version "4.1.9"
27382698 resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4"
62336193 hasown "^2.0.2"
62346194 side-channel "^1.1.0"
62356195
6236invariant@2.2.4, invariant@^2.2.4:
6196invariant@^2.2.4:
62376197 version "2.2.4"
62386198 resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
62396199 integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
72227182 object.assign "^4.1.4"
72237183 object.values "^1.1.6"
72247184
7225kdbush@^4.0.2:
7226 version "4.0.2"
7227 resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39"
7228 integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==
7229
72307185keyv@^4.5.3, keyv@^4.5.4:
72317186 version "4.5.4"
72327187 resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
98089763 tinyglobby "^0.2.11"
98099764 ts-interface-checker "^0.1.9"
98109765
9811supercluster@^8.0.1:
9812 version "8.0.1"
9813 resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-8.0.1.tgz#9946ba123538e9e9ab15de472531f604e7372df5"
9814 integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==
9815 dependencies:
9816 kdbush "^4.0.2"
9817
98189766supports-color@^5.3.0:
98199767 version "5.5.0"
98209768 resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
1042710375 dependencies:
1042810376 minimalistic-assert "^1.0.0"
1042910377
10378web-vitals@^4.2.4:
10379 version "4.2.4"
10380 resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7"
10381 integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==
10382
1043010383webidl-conversions@^4.0.2:
1043110384 version "4.0.2"
1043210385 resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
Deletedrender.yaml+0−12View fileUnifiedSplit
1services:
2 - type: web
3 name: hibiscustoairport-backend
4 runtime: docker
5 plan: starter
6 # Render will detect Dockerfile at repo root by default
7 # dockerfilePath: ./Dockerfile
8 # dockerContext: .
9 envVars:
10 - key: PORT
11 value: "10000"
12 healthCheckPath: /debug/stamp
130
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts