Claude/audit website bookings 7z bvm #3723
10 changed files+1072−738
Modified.env.example+5−10View fileUnifiedSplit
@@ -1,6 +1,5 @@
1# Database Configuration
2MONGO_URL=mongodb+srv://username:password@cluster.mongodb.net/?retryWrites=true&w=majority
3DB_NAME=hibiscus_airport
1# Database Configuration (Neon PostgreSQL)
2DATABASE_URL=postgresql://username:password@ep-xxx.us-east-2.aws.neon.tech/hibiscus_airport?sslmode=require
43
54# Stripe Payment
65STRIPE_SECRET_KEY=sk_test_xxxxx
@@ -19,15 +18,11 @@ GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
1918GOOGLE_CALENDAR_ID=primary
2019BACKEND_URL=https://hibiscustoairport-backend.onrender.com
2120
22# Email — Gmail API (primary) or SMTP (fallback)
23GOOGLE_SERVICE_ACCOUNT_FILE=service-account.json
21# Email — Mailgun
22MAILGUN_API_KEY=your-mailgun-api-key
23MAILGUN_DOMAIN=mg.bookaride.co.nz
2424SENDER_EMAIL=noreply@bookaride.co.nz
2525ADMIN_EMAIL=bookings@bookaride.co.nz
26# SMTP fallback (only if Google credentials not set)
27# SMTP_HOST=smtp.gmail.com
28# SMTP_PORT=587
29# SMTP_USER=
30# SMTP_PASS=
3126
3227# Application URLs
3328FRONTEND_URL=https://hibiscustoairport.co.nz
ModifiedCLAUDE.md+10−6View fileUnifiedSplit
@@ -12,23 +12,27 @@
1212- **Hours:** 24/7 including public holidays
1313- **Currency:** NZD
1414
15## Architecture
15## Architecture (DO NOT CHANGE WITHOUT OWNER APPROVAL)
1616
1717- **Frontend:** React app deployed on Vercel
1818- **Backend:** FastAPI (Python) deployed on Render
19- **Database:** MongoDB (Motor async driver)
19- **Database:** Neon (PostgreSQL) via asyncpg — DO NOT use MongoDB
2020- **Payments:** Stripe
2121- **SMS:** Twilio
22- **Email:** Gmail API (service account) with SMTP fallback
22- **Email:** Mailgun HTTP API — DO NOT use Gmail API or raw SMTP
2323- **Analytics:** PostHog
24- **Geocoding / Autocomplete:** Google Maps API (`@react-google-maps/api`) — API key set in Vercel as `REACT_APP_GOOGLE_MAPS_API_KEY`. Falls back to plain text inputs when key is missing.
2425
2526## Important Rules for AI Sessions
2627
271. **Never invent contact details.** Use only the emails and phone numbers listed above. If you don't know a detail, ask - don't guess.
281. **Never invent contact details.** Use only the emails and phone numbers listed above. If you don't know a detail, ask — don't guess.
28292. **No contact form.** The site drives users to book directly. The Contact section is a booking CTA with phone/email, not a message form.
29303. **Don't create backup files.** Edit files in place.
30314. **Don't rename or reorganise files** without being asked.
31325. **Test changes** against existing patterns in the codebase before introducing new ones.
336. **Never swap out the database or email provider.** The stack is Neon (PostgreSQL) + Mailgun. Do not introduce MongoDB, Firebase, Gmail API, SendGrid, or any other provider.
347. **Never change business contact details** (phone, emails, website URL). These are listed above and must not be altered.
358. **Phone number is 021 743 321.** Any other phone number (e.g., 021 123 4567) is wrong. Fix it if you see it.
3236
3337## Key Files
3438
@@ -37,6 +41,6 @@
3741- `frontend/src/pages/BookingPage.jsx` - Booking form
3842- `backend/booking_routes.py` - Booking API endpoints
3943- `backend/admin_routes.py` - Admin dashboard API
40- `backend/utils.py` - Email, SMS, pricing engine
44- `backend/utils.py` - Email (Mailgun), SMS (Twilio), pricing engine
4145- `backend/server.py` - FastAPI app setup, CORS, middleware
42- `backend/db.py` - MongoDB connection
46- `backend/db.py` - Neon PostgreSQL connection and schema init
Modifiedbackend/admin_routes.py+29−6View fileUnifiedSplit
@@ -283,15 +283,38 @@ setInterval(loadBookings, 30000);
283283
284284
285285async def admin_bookings_list(req: Request):
286 """Fetch bookings from MongoDB for the server-rendered admin panel."""
286 """Fetch bookings from PostgreSQL for the server-rendered admin panel."""
287287 if not _require(req):
288288 return JSONResponse({"ok": False, "error": "unauthorized"}, status_code=401)
289289 try:
290 from db import db
291 if db is None:
292 return JSONResponse({"ok": False, "error": "MONGO_URL not set", "items": []})
293 docs = await db.bookings.find({}, {"_id": 0}).sort("createdAt", -1).to_list(500)
294 return JSONResponse({"ok": True, "count": len(docs), "items": docs})
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})
295318 except Exception as e:
296319 logger.error(f"admin_bookings_list error: {e}")
297320 return JSONResponse({"ok": False, "error": str(e), "items": []})
Modifiedbackend/booking_routes.py+563−346View fileUnifiedSplit
Large file (1,516 lines). Load full file
Modifiedbackend/db.py+183−9View fileUnifiedSplit
@@ -1,18 +1,192 @@
11# backend/db.py
2# Shared MongoDB connection — import `db` from this module everywhere.
3# This avoids creating multiple MongoClient instances.
2# Neon PostgreSQL connection pool — import `get_pool` and use it everywhere.
43
54import os
65import logging
7from motor.motor_asyncio import AsyncIOMotorClient
6import asyncpg
87
98logger = logging.getLogger(__name__)
109
11_mongo_url = os.environ.get("MONGO_URL", "")
12_db_name = os.environ.get("DB_NAME", "hibiscus_shuttle")
10_pool: asyncpg.Pool | None = None
1311
14if not _mongo_url:
15 logger.warning("MONGO_URL not set — database operations will fail at runtime")
12DATABASE_URL = os.environ.get("DATABASE_URL", "")
1613
17client: AsyncIOMotorClient = AsyncIOMotorClient(_mongo_url) if _mongo_url else None
18db = client[_db_name] if client else None
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
Modifiedbackend/requirements.txt+1−79View fileUnifiedSplit
@@ -1,122 +1,48 @@
1aiohappyeyeballs==2.6.1
2aiohttp==3.13.2
3aiohttp-retry==2.9.1
4aiosignal==1.4.0
51annotated-types==0.7.0
62anyio==4.11.0
73APScheduler==3.11.2
8attrs==25.4.0
4asyncpg==0.30.0
95bcrypt==4.1.3
10black==25.11.0
11boto3==1.41.3
12botocore==1.41.3
136cachetools==6.2.2
147certifi==2025.11.12
158cffi==2.0.0
169charset-normalizer==3.4.4
1710click==8.3.1
1811cryptography==46.0.3
19distro==1.9.0
20dnspython==2.8.0
2112ecdsa==0.19.1
2213email-validator==2.3.0
2314fastapi==0.110.1
24fastuuid==0.14.0
25filelock==3.20.0
26flake8==7.3.0
27frozenlist==1.8.0
28fsspec==2025.12.0
29google-ai-generativelanguage==0.6.15
30google-api-core==2.28.1
3115google-api-python-client==2.187.0
3216google-auth==2.41.1
3317google-auth-httplib2==0.2.1
3418google-auth-oauthlib==1.2.3
35google-genai==1.55.0
36google-generativeai==0.8.5
3719googleapis-common-protos==1.72.0
38googlemaps==4.10.0
39grpcio==1.76.0
40grpcio-status==1.71.2
4120h11==0.16.0
42hf-xet==1.2.0
4321httpcore==1.0.9
4422httplib2==0.31.0
4523httpx==0.28.1
46huggingface_hub==1.2.2
4724icalendar==6.3.2
4825idna==3.11
49importlib_metadata==8.7.0
50iniconfig==2.3.0
51isort==7.0.0
52Jinja2==3.1.6
53jiter==0.12.0
54jmespath==1.0.1
55jq==1.10.0
56jsonschema==4.25.1
57jsonschema-specifications==2025.9.1
58litellm==1.80.0
59markdown-it-py==4.0.0
60MarkupSafe==3.0.3
61mccabe==0.7.0
62mdurl==0.1.2
63motor==3.3.1
64multidict==6.7.0
65mypy==1.18.2
66mypy_extensions==1.1.0
67numpy==2.3.5
68oauthlib==3.3.1
69openai==1.99.9
70packaging==25.0
71pandas==2.3.3
7226passlib==1.7.4
73pathspec==0.12.1
74pillow==12.0.0
75platformdirs==4.5.0
76pluggy==1.6.0
77propcache==0.4.1
78proto-plus==1.26.1
79protobuf==5.29.5
8027pyasn1==0.6.1
8128pyasn1_modules==0.4.2
82pycodestyle==2.14.0
8329pycparser==2.23
8430pydantic==2.12.4
8531pydantic_core==2.41.5
86pyflakes==3.4.0
87Pygments==2.19.2
8832PyJWT==2.10.1
89pymongo==4.5.0
90pyparsing==3.2.5
91pytest==9.0.1
9233python-dateutil==2.9.0.post0
9334python-dotenv==1.2.1
9435python-jose==3.5.0
9536python-multipart==0.0.20
96pytokens==0.3.0
9737pytz==2025.2
98PyYAML==6.0.3
99referencing==0.37.0
100regex==2025.11.3
10138requests==2.32.5
10239requests-oauthlib==2.0.0
103rich==14.2.0
104rpds-py==0.30.0
10540rsa==4.9.1
106s3transfer==0.15.0
107s5cmd==0.2.0
108shellingham==1.5.4
10941six==1.17.0
11042sniffio==1.3.1
11143starlette==0.37.2
11244stripe==14.0.1
113tenacity==9.1.2
114tiktoken==0.12.0
115tokenizers==0.22.1
116tqdm==4.67.1
11745twilio==9.8.8
118typer==0.20.0
119typer-slim==0.20.0
12046typing-inspection==0.4.2
12147typing_extensions==4.15.0
12248tzdata==2025.2
@@ -125,7 +51,3 @@ uritemplate==4.2.0
12551urllib3==2.5.0
12652uvicorn==0.25.0
12753vobject==0.9.9
128watchfiles==1.1.1
129websockets==15.0.1
130yarl==1.22.0
131zipp==3.23.0
Modifiedbackend/server.py+21−19View fileUnifiedSplit
@@ -166,7 +166,7 @@ def agents_ping():
166166try:
167167 from apscheduler.schedulers.asyncio import AsyncIOScheduler
168168 from apscheduler.triggers.cron import CronTrigger
169 from db import db as shared_db
169 from db import get_pool, close_pool
170170
171171 scheduler = AsyncIOScheduler()
172172
@@ -174,17 +174,17 @@ try:
174174 """Send reminders for bookings happening tomorrow — runs daily at 6 PM NZ time."""
175175 try:
176176 logger.info("Running day-before reminder job...")
177 if shared_db is None:
178 logger.warning("MongoDB not connected, skipping reminders")
179 return
177 pool = await get_pool()
180178 tomorrow = (datetime.now(timezone.utc) + timedelta(days=1)).strftime('%Y-%m-%d')
181 bookings = await shared_db.bookings.find({
182 "date": tomorrow,
183 "status": "confirmed",
184 "payment_status": "paid",
185 "reminder_sent": {"$ne": True}
186 }, {"_id": 0}).to_list(100)
187 logger.info(f"Found {len(bookings)} bookings for tomorrow ({tomorrow}) needing reminders")
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")
188188
189189 try:
190190 from utils import send_email, send_sms, format_date_nz
@@ -193,7 +193,8 @@ try:
193193 return
194194
195195 sent_count = 0
196 for booking in bookings:
196 for row in rows:
197 booking = dict(row)
197198 try:
198199 booking_ref = booking.get('booking_ref', 'N/A')
199200 formatted_date = format_date_nz(booking['date'])
@@ -210,8 +211,8 @@ try:
210211 <div style="background:#f8fafc;padding:20px;border-radius:8px;margin:20px 0;border-left:4px solid #f59e0b;">
211212 <p><strong>Booking:</strong> {booking_ref}</p>
212213 <p><strong>Date & Time:</strong> {formatted_date} at {booking['time']}</p>
213 <p><strong>Pickup:</strong> {booking['pickupAddress']}</p>
214 <p><strong>Drop-off:</strong> {booking['dropoffAddress']}</p>
214 <p><strong>Pickup:</strong> {booking['pickup_address']}</p>
215 <p><strong>Drop-off:</strong> {booking['dropoff_address']}</p>
215216 </div>
216217 <p>Questions? Contact us at 021 743 321 or bookings@bookaride.co.nz</p>
217218 </div>
@@ -222,14 +223,14 @@ try:
222223 f"REMINDER: Your airport transfer is tomorrow!\n"
223224 f"Ref: {booking_ref}\n"
224225 f"Pickup: {formatted_date} at {booking['time']}\n"
225 f"From: {booking['pickupAddress'][:50]}\n"
226 f"From: {(booking['pickup_address'] or '')[:50]}\n"
226227 f"Be ready 5-10 mins early. Questions? 021 743 321"
227228 )
228229 send_sms(booking['phone'], sms_message)
229230
230 await shared_db.bookings.update_one(
231 {"id": booking['id']},
232 {"$set": {"reminder_sent": True, "reminder_sent_at": datetime.now(timezone.utc).isoformat()}}
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']
233234 )
234235 sent_count += 1
235236 logger.info(f"Reminder sent for booking {booking_ref}")
@@ -254,7 +255,8 @@ try:
254255
255256 async def shutdown_scheduler():
256257 scheduler.shutdown()
257 logger.info("Scheduler shutdown")
258 await close_pool()
259 logger.info("Scheduler and DB pool shutdown")
258260
259261except Exception as e:
260262 logger.warning(f"Scheduler not available: {e}")
Modifiedbackend/utils.py+72−127View fileUnifiedSplit
@@ -1,15 +1,11 @@
11import os
22import requests
3import smtplib
4from email.mime.text import MIMEText
5from email.mime.multipart import MIMEMultipart
6from email.mime.base import MIMEBase
7from email import encoders
83from twilio.rest import Client
94import logging
105from datetime import datetime
116import vobject
127import uuid
8from db import get_pool
139
1410# iCloud/CardDAV Configuration
1511ICLOUD_USERNAME = os.environ.get('ICLOUD_USERNAME', '')
@@ -18,22 +14,21 @@ CARDDAV_URL = "https://contacts.icloud.com"
1814
1915
2016# Booking Reference Generator
21async def generate_booking_reference(db):
17async def generate_booking_reference():
2218 """Generate booking reference starting from H1, H2, H3..."""
2319 try:
24 # Get the last booking to determine next number
25 last_booking = await db.bookings.find_one(
26 {"booking_ref": {"$exists": True}},
27 sort=[("createdAt", -1)]
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"
2823 )
29
30 if last_booking and 'booking_ref' in last_booking:
24
25 if row and row['booking_ref']:
3126 # Extract number from H123 format
32 last_num = int(last_booking['booking_ref'][1:])
27 last_num = int(row['booking_ref'][1:])
3328 next_num = last_num + 1
3429 else:
3530 next_num = 1
36
31
3732 return f"H{next_num}"
3833 except Exception as e:
3934 logger.error(f"Error generating booking reference: {str(e)}")
@@ -113,45 +108,38 @@ def sync_contact_to_icloud(booking: dict):
113108 # Method 2: Email vCard to iCloud email (opens as contact on iPhone)
114109 # Send vCard as email attachment to iCloud email
115110 icloud_email = f"{ICLOUD_USERNAME}@icloud.com" if ICLOUD_USERNAME else None
116
111
117112 if icloud_email:
118113 try:
119 # Create email with vCard attachment
120 from email.mime.multipart import MIMEMultipart
121 from email.mime.text import MIMEText
122 from email.mime.base import MIMEBase
123 from email import encoders
124 import smtplib
125
126 msg = MIMEMultipart()
127 msg['From'] = os.environ.get('SENDER_EMAIL', 'noreply@bookaride.co.nz')
128 msg['To'] = icloud_email
129 msg['Subject'] = f"New Customer Contact - {booking.get('name')} ({booking_ref})"
130
131 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."
132 msg.attach(MIMEText(body, 'plain'))
133
134 # Attach vCard
135 vcard_attachment = MIMEBase('text', 'vcard', name=f"{booking.get('name', 'contact')}.vcf")
136 vcard_attachment.set_payload(vcard_data)
137 encoders.encode_base64(vcard_attachment)
138 vcard_attachment.add_header('Content-Disposition', 'attachment', filename=f"{booking.get('name', 'contact')}.vcf")
139 msg.attach(vcard_attachment)
140
141 # Send via SMTP
142 smtp_server = os.environ.get('SMTP_SERVER', 'smtp.gmail.com')
143 smtp_port = int(os.environ.get('SMTP_PORT', 587))
144 smtp_user = os.environ.get('SMTP_USERNAME')
145 smtp_pass = os.environ.get('SMTP_PASSWORD')
146
147 with smtplib.SMTP(smtp_server, smtp_port) as server:
148 server.starttls()
149 server.login(smtp_user, smtp_pass)
150 server.send_message(msg)
151
152 logger.info(f"Contact vCard emailed to iCloud: {booking.get('name')} ({booking_ref})")
153 return True
154
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
155143 except Exception as email_error:
156144 logger.error(f"Failed to email vCard to iCloud: {str(email_error)}")
157145 return False
@@ -329,57 +317,23 @@ def calculate_price(distance_km: float, passengers: int = 1, vip_pickup: bool =
329317 'ratePerKm': rate_per_km
330318 }
331319
332# Email Notifications — via Gmail API (service account or OAuth)
333#
334# Requires either:
335# GOOGLE_SERVICE_ACCOUNT_FILE – path to service-account JSON with domain-wide delegation
336# GOOGLE_CREDENTIALS_JSON – inline JSON credentials (for container deployments)
337# And:
338# SENDER_EMAIL – the "from" address (must be in the Google Workspace domain)
320# Email Notifications — via Mailgun HTTP API
339321#
340# Falls back to SMTP if GOOGLE_SERVICE_ACCOUNT_FILE / GOOGLE_CREDENTIALS_JSON are not set
341# (so the old SMTP_* env vars still work as a safety net).
342
343def _get_gmail_service():
344 """Build a Gmail API service using service-account credentials with domain-wide delegation."""
345 try:
346 from google.oauth2 import service_account
347 from googleapiclient.discovery import build
348 import json
349
350 SCOPES = ['https://www.googleapis.com/auth/gmail.send']
351 sender = os.environ.get('SENDER_EMAIL', 'noreply@bookaride.co.nz')
352
353 sa_file = os.environ.get('GOOGLE_SERVICE_ACCOUNT_FILE', '')
354 sa_json = os.environ.get('GOOGLE_CREDENTIALS_JSON', '')
355
356 if sa_file:
357 creds = service_account.Credentials.from_service_account_file(
358 sa_file, scopes=SCOPES
359 ).with_subject(sender)
360 elif sa_json:
361 info = json.loads(sa_json)
362 creds = service_account.Credentials.from_service_account_info(
363 info, scopes=SCOPES
364 ).with_subject(sender)
365 else:
366 return None # no credentials — fall through to SMTP
367
368 return build('gmail', 'v1', credentials=creds, cache_discovery=False)
369 except Exception as e:
370 logger.error(f"Failed to build Gmail API service: {e}")
371 return None
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)
372326
373327def send_email(to_email: str, subject: str, body: str, calendar_invite=None):
374 """Send email via Gmail API (preferred) with SMTP fallback."""
328 """Send email via Mailgun HTTP API."""
375329 try:
330 api_key = os.environ.get('MAILGUN_API_KEY')
331 domain = os.environ.get('MAILGUN_DOMAIN')
376332 sender_email = os.environ.get('SENDER_EMAIL', 'noreply@bookaride.co.nz')
377333
378 # Build MIME message
379 msg = MIMEMultipart('mixed')
380 msg['From'] = sender_email
381 msg['To'] = to_email
382 msg['Subject'] = subject
334 if not api_key or not domain:
335 logger.error("Mailgun not configured (set MAILGUN_API_KEY and MAILGUN_DOMAIN)")
336 return False
383337
384338 html_body = f"""
385339 <html>
@@ -388,41 +342,32 @@ def send_email(to_email: str, subject: str, body: str, calendar_invite=None):
388342 </body>
389343 </html>
390344 """
391 msg.attach(MIMEText(html_body, 'html'))
392345
346 data = {
347 "from": sender_email,
348 "to": [to_email],
349 "subject": subject,
350 "html": html_body,
351 }
352
353 files = []
393354 if calendar_invite:
394 ical_part = MIMEBase('text', 'calendar', method='REQUEST', name='booking.ics')
395 ical_part.set_payload(calendar_invite)
396 encoders.encode_base64(ical_part)
397 ical_part.add_header('Content-Disposition', 'attachment', filename='booking.ics')
398 msg.attach(ical_part)
399
400 # --- Try Gmail API first ---
401 gmail = _get_gmail_service()
402 if gmail:
403 import base64
404 raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
405 gmail.users().messages().send(
406 userId='me', body={'raw': raw}
407 ).execute()
408 logger.info(f"Email sent via Gmail API to {to_email}")
409 return True
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 )
410364
411 # --- SMTP fallback ---
412 smtp_server = os.environ.get('SMTP_SERVER', '')
413 smtp_username = os.environ.get('SMTP_USERNAME', '')
414 smtp_password = os.environ.get('SMTP_PASSWORD', '')
415 if smtp_server and smtp_username:
416 smtp_port = int(os.environ.get('SMTP_PORT', 587))
417 with smtplib.SMTP(smtp_server, smtp_port) as server:
418 server.starttls()
419 server.login(smtp_username, smtp_password)
420 server.send_message(msg)
421 logger.info(f"Email sent via SMTP to {to_email}")
365 if response.status_code == 200:
366 logger.info(f"Email sent via Mailgun to {to_email}")
422367 return True
423
424 logger.error("No email provider configured (set GOOGLE_SERVICE_ACCOUNT_FILE or SMTP_SERVER)")
425 return False
368 else:
369 logger.error(f"Mailgun API error ({response.status_code}): {response.text}")
370 return False
426371 except Exception as e:
427372 logger.error(f"Error sending email: {str(e)}")
428373 return False
Modifiedfrontend/src/pages/AdminEditBooking.jsx+92−102View fileUnifiedSplit
@@ -7,9 +7,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'
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, Autocomplete } from '@react-google-maps/api';
1011
1112import { BACKEND_URL, GOOGLE_MAPS_API_KEY } from '../config';
1213
14const libraries = ['places'];
15
1316const AdminEditBooking = () => {
1417 const navigate = useNavigate();
1518 const { bookingId } = useParams();
@@ -18,8 +21,14 @@ const AdminEditBooking = () => {
1821 const [saving, setSaving] = useState(false);
1922 const [calculatingPrice, setCalculatingPrice] = useState(false);
2023
21 const pickupInputRef = useRef(null);
22 const dropoffInputRef = useRef(null);
24 const [pickupAutocomplete, setPickupAutocomplete] = useState(null);
25 const [dropoffAutocomplete, setDropoffAutocomplete] = useState(null);
26 const additionalAutocompleteRefs = useRef({});
27
28 const { isLoaded } = useLoadScript({
29 googleMapsApiKey: GOOGLE_MAPS_API_KEY,
30 libraries,
31 });
2332
2433 const [formData, setFormData] = useState({
2534 name: '',
@@ -39,7 +48,6 @@ const AdminEditBooking = () => {
3948
4049 const [pricing, setPricing] = useState(null);
4150 const [priceOverride, setPriceOverride] = useState('');
42 const additionalPickupRefs = useRef({});
4351
4452 useEffect(() => {
4553 const token = localStorage.getItem('admin_token');
@@ -48,83 +56,46 @@ const AdminEditBooking = () => {
4856 return;
4957 }
5058 fetchBooking();
51 loadGoogleMaps();
5259 }, [navigate, bookingId]);
5360
54 const loadGoogleMaps = () => {
55 if (window.google?.maps?.places) {
56 initAutocomplete();
57 return;
58 }
61 const onPickupLoad = (autocomplete) => {
62 setPickupAutocomplete(autocomplete);
63 };
5964
60 if (document.querySelector('script[src*="maps.googleapis.com"]')) {
61 const checkLoaded = setInterval(() => {
62 if (window.google?.maps?.places) {
63 clearInterval(checkLoaded);
64 initAutocomplete();
65 }
66 }, 100);
67 return;
65 const onPickupPlaceChanged = () => {
66 if (pickupAutocomplete !== null) {
67 const place = pickupAutocomplete.getPlace();
68 if (place.formatted_address) {
69 setFormData(prev => ({ ...prev, pickupAddress: place.formatted_address }));
70 }
6871 }
72 };
6973
70 const script = document.createElement('script');
71 script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_MAPS_API_KEY}&libraries=places`;
72 script.async = true;
73 script.defer = true;
74 script.onload = () => initAutocomplete();
75 document.head.appendChild(script);
74 const onDropoffLoad = (autocomplete) => {
75 setDropoffAutocomplete(autocomplete);
7676 };
7777
78 const initAutocomplete = () => {
79 setTimeout(() => {
80 if (pickupInputRef.current) {
81 const pickupAutocomplete = new window.google.maps.places.Autocomplete(pickupInputRef.current, {
82 componentRestrictions: { country: 'nz' },
83 types: ['address']
84 });
85 pickupAutocomplete.addListener('place_changed', () => {
86 const place = pickupAutocomplete.getPlace();
87 if (place.formatted_address) {
88 setFormData(prev => ({ ...prev, pickupAddress: place.formatted_address }));
89 }
90 });
91 }
92
93 if (dropoffInputRef.current) {
94 const dropoffAutocomplete = new window.google.maps.places.Autocomplete(dropoffInputRef.current, {
95 componentRestrictions: { country: 'nz' },
96 types: ['address']
97 });
98 dropoffAutocomplete.addListener('place_changed', () => {
99 const place = dropoffAutocomplete.getPlace();
100 if (place.formatted_address) {
101 setFormData(prev => ({ ...prev, dropoffAddress: place.formatted_address }));
102 }
103 });
78 const onDropoffPlaceChanged = () => {
79 if (dropoffAutocomplete !== null) {
80 const place = dropoffAutocomplete.getPlace();
81 if (place.formatted_address) {
82 setFormData(prev => ({ ...prev, dropoffAddress: place.formatted_address }));
10483 }
105
106 // Initialize autocomplete for existing additional pickups
107 initAdditionalAutocomplete();
108 }, 500);
84 }
85 };
86
87 const onAdditionalPickupLoad = (autocomplete, index) => {
88 additionalAutocompleteRefs.current[index] = autocomplete;
10989 };
11090
111 const initAdditionalAutocomplete = () => {
112 Object.keys(additionalPickupRefs.current).forEach(index => {
113 const input = additionalPickupRefs.current[index];
114 if (input && !input._autocomplete) {
115 const autocomplete = new window.google.maps.places.Autocomplete(input, {
116 componentRestrictions: { country: 'nz' },
117 types: ['address']
118 });
119 autocomplete.addListener('place_changed', () => {
120 const place = autocomplete.getPlace();
121 if (place.formatted_address) {
122 updateAdditionalPickup(parseInt(index), place.formatted_address);
123 }
124 });
125 input._autocomplete = autocomplete;
91 const onAdditionalPickupPlaceChanged = (index) => {
92 const autocomplete = additionalAutocompleteRefs.current[index];
93 if (autocomplete) {
94 const place = autocomplete.getPlace();
95 if (place && (place.formatted_address || place.name)) {
96 updateAdditionalPickup(index, place.formatted_address || place.name);
12697 }
127 });
98 }
12899 };
129100
130101 const addPickupLocation = () => {
@@ -132,13 +103,11 @@ const AdminEditBooking = () => {
132103 ...prev,
133104 additionalPickups: [...prev.additionalPickups, '']
134105 }));
135 // Initialize autocomplete for new field after render
136 setTimeout(() => initAdditionalAutocomplete(), 100);
137106 };
138107
139108 const removePickupLocation = (index) => {
140109 const newPickups = formData.additionalPickups.filter((_, i) => i !== index);
141 delete additionalPickupRefs.current[index];
110 delete additionalAutocompleteRefs.current[index];
142111 setFormData(prev => ({
143112 ...prev,
144113 additionalPickups: newPickups
@@ -177,9 +146,6 @@ const AdminEditBooking = () => {
177146 payment_status: booking.payment_status || 'unpaid'
178147 });
179148
180 // Initialize autocomplete for additional pickups after data loads
181 setTimeout(() => initAdditionalAutocomplete(), 600);
182
183149 if (booking.pricing) {
184150 setPricing(booking.pricing);
185151 }
@@ -264,7 +230,7 @@ const AdminEditBooking = () => {
264230 return dateStr.split('T')[0];
265231 };
266232
267 if (loading) {
233 if (loading || !isLoaded) {
268234 return (
269235 <div className="min-h-screen bg-gray-50 flex items-center justify-center">
270236 <Loader2 className="w-8 h-8 animate-spin text-gold" />
@@ -343,16 +309,24 @@ const AdminEditBooking = () => {
343309 <CardContent className="p-6 space-y-4">
344310 <div>
345311 <label className="block text-sm font-medium text-gray-700 mb-2">Pickup Address</label>
346 <Input
347 ref={pickupInputRef}
348 value={formData.pickupAddress}
349 onChange={(e) => setFormData({ ...formData, pickupAddress: e.target.value })}
350 placeholder="Enter pickup address"
351 required
352 className="h-12"
353 />
312 <Autocomplete
313 onLoad={onPickupLoad}
314 onPlaceChanged={onPickupPlaceChanged}
315 options={{
316 componentRestrictions: { country: 'nz' },
317 fields: ['formatted_address', 'name']
318 }}
319 >
320 <Input
321 value={formData.pickupAddress}
322 onChange={(e) => setFormData({ ...formData, pickupAddress: e.target.value })}
323 placeholder="Enter pickup address"
324 required
325 className="h-12"
326 />
327 </Autocomplete>
354328 </div>
355
329
356330 {/* Additional Pickup Locations */}
357331 {formData.additionalPickups.map((pickup, index) => (
358332 <div key={index} className="flex gap-3">
@@ -362,13 +336,21 @@ const AdminEditBooking = () => {
362336 </label>
363337 <div className="relative">
364338 <MapPin className="absolute left-3 top-3 h-5 w-5 text-purple-500 z-10" />
365 <Input
366 ref={(el) => { additionalPickupRefs.current[index] = el; }}
367 value={pickup}
368 onChange={(e) => updateAdditionalPickup(index, e.target.value)}
369 placeholder="Search for address..."
370 className="pl-10 h-12"
371 />
339 <Autocomplete
340 onLoad={(autocomplete) => onAdditionalPickupLoad(autocomplete, index)}
341 onPlaceChanged={() => onAdditionalPickupPlaceChanged(index)}
342 options={{
343 componentRestrictions: { country: 'nz' },
344 fields: ['formatted_address', 'name']
345 }}
346 >
347 <Input
348 value={pickup}
349 onChange={(e) => updateAdditionalPickup(index, e.target.value)}
350 placeholder="Search for address..."
351 className="pl-10 h-12"
352 />
353 </Autocomplete>
372354 </div>
373355 </div>
374356 <div className="flex items-end">
@@ -383,7 +365,7 @@ const AdminEditBooking = () => {
383365 </div>
384366 </div>
385367 ))}
386
368
387369 <Button
388370 type="button"
389371 onClick={addPickupLocation}
@@ -393,17 +375,25 @@ const AdminEditBooking = () => {
393375 <Plus className="w-4 h-4 mr-2" />
394376 Add Pickup Stop
395377 </Button>
396
378
397379 <div>
398380 <label className="block text-sm font-medium text-gray-700 mb-2">Drop-off Address</label>
399 <Input
400 ref={dropoffInputRef}
401 value={formData.dropoffAddress}
402 onChange={(e) => setFormData({ ...formData, dropoffAddress: e.target.value })}
403 placeholder="Enter drop-off address"
404 required
405 className="h-12"
406 />
381 <Autocomplete
382 onLoad={onDropoffLoad}
383 onPlaceChanged={onDropoffPlaceChanged}
384 options={{
385 componentRestrictions: { country: 'nz' },
386 fields: ['formatted_address', 'name']
387 }}
388 >
389 <Input
390 value={formData.dropoffAddress}
391 onChange={(e) => setFormData({ ...formData, dropoffAddress: e.target.value })}
392 placeholder="Enter drop-off address"
393 required
394 className="h-12"
395 />
396 </Autocomplete>
407397 </div>
408398 <div className="grid md:grid-cols-3 gap-4">
409399 <div>
Modifiedfrontend/src/pages/BookingPage.jsx+96−34View fileUnifiedSplit
@@ -222,7 +222,8 @@ const BookingPage = () => {
222222 arrivalTime: '',
223223 vipPickup: false,
224224 oversizedLuggage: false,
225 returnTrip: false
225 returnTrip: false,
226 paymentMethod: 'stripe'
226227 });
227228
228229 // Format date for display in NZ format (e.g., "Saturday, 27/12/2025")
@@ -306,7 +307,7 @@ const BookingPage = () => {
306307
307308 setPromoDiscount(response.data);
308309 toast({
309 title: '🎉 Promo Code Applied!',
310 title: '🎉 Promo Code Applied!',
310311 description: `You saved $${response.data.discount_amount.toFixed(2)}!`
311312 });
312313 } catch (error) {
@@ -434,7 +435,24 @@ const BookingPage = () => {
434435
435436 const handleSubmit = async (e) => {
436437 e.preventDefault();
437
438
439 // Validate required fields that aren't covered by native `required`
440 if (!formData.serviceType) {
441 toast({ title: "Service Type Required", description: "Please select a service type", variant: "destructive" });
442 return;
443 }
444 if (!formData.date) {
445 toast({ title: "Date Required", description: "Please select a pickup date", variant: "destructive" });
446 return;
447 }
448 if (!formData.time) {
449 toast({ title: "Time Required", description: "Please select a pickup time", variant: "destructive" });
450 return;
451 }
452 if (!formData.name || !formData.email || !formData.phone) {
453 toast({ title: "Contact Details Required", description: "Please fill in your name, email, and phone number", variant: "destructive" });
454 return;
455 }
438456 if (!pricing) {
439457 toast({
440458 title: "Calculate Price First",
@@ -447,23 +465,28 @@ const BookingPage = () => {
447465 setSubmitting(true);
448466
449467 try {
450 // Create booking first
468 // Create booking
451469 const bookingResponse = await axios.post(`${BACKEND_URL}/api/bookings`, {
452470 ...formData,
453 passengers: String(formData.passengers),
471 passengers: String(formData.passengers),
454472 pricing: pricing,
455 payment_method: 'stripe',
456 payment_status: 'pending'
473 payment_method: formData.paymentMethod === 'cash' ? 'cash' : 'stripe',
474 payment_status: formData.paymentMethod === 'cash' ? 'pay_on_day' : 'pending'
457475 });
458
476
459477 const newBookingId = bookingResponse.data.booking_id;
460
461 // Redirect to Stripe checkout
462 const checkoutResponse = await axios.post(`${BACKEND_URL}/api/payment/create-checkout`, {
463 booking_id: newBookingId
464 });
465 window.location.href = checkoutResponse.data.url;
466
478
479 if (formData.paymentMethod === 'cash') {
480 // Cash — no Stripe checkout, redirect to confirmation
481 window.location.href = `/booking/confirmation?id=${newBookingId}&method=cash`;
482 } else {
483 // Stripe checkout
484 const checkoutResponse = await axios.post(`${BACKEND_URL}/api/payment/create-checkout`, {
485 booking_id: newBookingId
486 });
487 window.location.href = checkoutResponse.data.url;
488 }
489
467490 } catch (error) {
468491 toast({
469492 title: "Booking Error",
@@ -492,13 +515,13 @@ const BookingPage = () => {
492515 <span className="font-medium">International Bookings Welcome</span>
493516 </div>
494517 <div className="flex items-center gap-1 text-gray-300">
495 <span className="text-gold">✓</span> 6 Languages
518 <span className=”text-gold”>✔</span> 6 Languages
496519 </div>
497 <div className="flex items-center gap-1 text-gray-300">
498 <span className="text-gold">✓</span> 7 Currencies
520 <div className=”flex items-center gap-1 text-gray-300”>
521 <span className=”text-gold”>✔</span> 7 Currencies
499522 </div>
500 <div className="flex items-center gap-1 text-gray-300">
501 <span className="text-gold">✓</span> Worldwide Payment
523 <div className=”flex items-center gap-1 text-gray-300”>
524 <span className=”text-gold”>✔</span> Worldwide Payment
502525 </div>
503526 </div>
504527 </div>
@@ -512,7 +535,7 @@ const BookingPage = () => {
512535 Book Your Transfer
513536 </h1>
514537 <p className="text-gray-600">
515 Get instant pricing • Secure payment • Confirmation in seconds
538 Get instant pricing • Secure payment • Confirmation in seconds
516539 </p>
517540 </div>
518541 </section>
@@ -611,7 +634,7 @@ const BookingPage = () => {
611634 size="sm"
612635 className="h-11 px-3 border-red-300 text-red-600 hover:bg-red-50"
613636 >
614 ✕
637 ✕
615638 </Button>
616639 </div>
617640 ))}
@@ -835,7 +858,7 @@ const BookingPage = () => {
835858 <p className="text-xs text-gray-500">We'll contact you to arrange return details</p>
836859 </div>
837860 </div>
838 <span className="font-semibold text-gold">×2</span>
861 <span className="font-semibold text-gold">×2</span>
839862 </label>
840863 </div>
841864 </div>
@@ -948,7 +971,7 @@ const BookingPage = () => {
948971 {pricing.returnTrip && (
949972 <div className="flex justify-between py-2 border-b border-gray-100">
950973 <span className="text-gray-600">Return Trip:</span>
951 <span className="font-semibold text-green-600">×2</span>
974 <span className="font-semibold text-green-600">×2</span>
952975 </div>
953976 )}
954977 </div>
@@ -966,7 +989,7 @@ const BookingPage = () => {
966989 {promoDiscount ? (
967990 <div className="space-y-2">
968991 <div className="flex justify-between items-center text-green-600">
969 <span className="text-sm font-medium">🎉 {promoDiscount.code}</span>
992 <span className="text-sm font-medium">🎉 {promoDiscount.code}</span>
970993 <span>-${promoDiscount.discount_amount.toFixed(2)}</span>
971994 </div>
972995 <div className="flex justify-between items-center">
@@ -1005,24 +1028,62 @@ const BookingPage = () => {
10051028 </div>
10061029 )}
10071030 </div>
1008 <p className="text-xs text-gray-600 mt-2">NZD • Price includes GST</p>
1031 <p className="text-xs text-gray-600 mt-2">NZD • Price includes GST</p>
10091032 </div>
10101033
1011 {/* Payment Info - Stripe Only */}
1012 <div className="mt-6">
1013 <div className="flex items-center p-4 bg-gradient-to-r from-indigo-50 to-purple-50 border-2 border-indigo-200 rounded-xl">
1034 {/* Payment Method Selection */}
1035 <div className="mt-6 space-y-3">
1036 <h3 className="text-sm font-semibold text-gray-700">Payment Method</h3>
1037 <label
1038 className={`flex items-center p-4 rounded-xl border-2 cursor-pointer transition-all ${
1039 formData.paymentMethod === 'stripe'
1040 ? 'border-indigo-400 bg-gradient-to-r from-indigo-50 to-purple-50'
1041 : 'border-gray-200 hover:border-gray-300'
1042 }`}
1043 >
1044 <input
1045 type="radio"
1046 name="paymentMethod"
1047 value="stripe"
1048 checked={formData.paymentMethod === 'stripe'}
1049 onChange={handleChange}
1050 className="sr-only"
1051 />
10141052 <div className="w-10 h-10 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-lg flex items-center justify-center mr-4">
1015 <span className="text-white text-lg">💳</span>
1053 <span className="text-white text-lg">💳</span>
10161054 </div>
10171055 <div>
1018 <span className="font-semibold text-gray-900">Secure Card Payment</span>
1019 <p className="text-sm text-gray-600">Pay securely with Credit or Debit Card via Stripe</p>
1056 <span className="font-semibold text-gray-900">Pay Online</span>
1057 <p className="text-sm text-gray-600">Credit or Debit Card via Stripe</p>
10201058 </div>
10211059 <div className="ml-auto flex items-center gap-1">
10221060 <span className="text-xs text-gray-500">Powered by</span>
10231061 <span className="font-bold text-indigo-600">Stripe</span>
10241062 </div>
1025 </div>
1063 </label>
1064 <label
1065 className={`flex items-center p-4 rounded-xl border-2 cursor-pointer transition-all ${
1066 formData.paymentMethod === 'cash'
1067 ? 'border-green-400 bg-green-50'
1068 : 'border-gray-200 hover:border-gray-300'
1069 }`}
1070 >
1071 <input
1072 type="radio"
1073 name="paymentMethod"
1074 value="cash"
1075 checked={formData.paymentMethod === 'cash'}
1076 onChange={handleChange}
1077 className="sr-only"
1078 />
1079 <div className="w-10 h-10 bg-green-600 rounded-lg flex items-center justify-center mr-4">
1080 <DollarSign className="w-5 h-5 text-white" />
1081 </div>
1082 <div>
1083 <span className="font-semibold text-gray-900">Pay Cash</span>
1084 <p className="text-sm text-gray-600">Pay the driver on the day</p>
1085 </div>
1086 </label>
10261087 </div>
10271088 </div>
10281089 )}
@@ -1039,7 +1100,8 @@ const BookingPage = () => {
10391100 Processing...
10401101 </>
10411102 ) : (
1042 <>💳 Pay with Card</>
1103 <>{formData.paymentMethod === 'cash' ? 'Confirm Booking' : '💳 Pay with Card'}</>
1104
10431105 )}
10441106 </Button>
10451107 </div>
10461108
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts