Claude/setup vercel bookaride v2 d5cfe #4503
28 changed files+3698−61
Addedbackend/.env.example+66−0View fileUnifiedSplit
@@ -0,0 +1,66 @@
1# ============================================================
2# BookARide V2 — Environment Variables
3# Copy this file to .env and fill in your values.
4# Never commit .env to version control.
5# ============================================================
6
7# ------------------------------------------------------------
8# DATABASE (Neon PostgreSQL)
9# Get this from: https://console.neon.tech → your project → Connection string
10# Format: postgresql://user:password@host/dbname?sslmode=require
11# ------------------------------------------------------------
12DATABASE_URL=postgresql://user:password@ep-xxx.ap-southeast-1.aws.neon.tech/bookaride?sslmode=require
13
14# ------------------------------------------------------------
15# AUTH
16# JWT_SECRET_KEY: Use a long random string in production.
17# Generate one with: openssl rand -hex 32
18# ------------------------------------------------------------
19JWT_SECRET_KEY=change-me-use-openssl-rand-hex-32
20# JWT_ALGORITHM and JWT_EXPIRATION_HOURS have defaults (HS256, 24h)
21
22# ------------------------------------------------------------
23# STRIPE (Payments)
24# Live keys from: https://dashboard.stripe.com/apikeys
25# Webhook secret from: https://dashboard.stripe.com/webhooks
26# ------------------------------------------------------------
27STRIPE_SECRET_KEY=sk_live_...
28STRIPE_WEBHOOK_SECRET=whsec_...
29
30# ------------------------------------------------------------
31# MAILGUN (Email)
32# ------------------------------------------------------------
33MAILGUN_API_KEY=key-...
34MAILGUN_DOMAIN=bookaride.co.nz
35
36# ------------------------------------------------------------
37# TWILIO (SMS)
38# ------------------------------------------------------------
39TWILIO_ACCOUNT_SID=AC...
40TWILIO_AUTH_TOKEN=...
41TWILIO_PHONE_NUMBER=+64...
42
43# ------------------------------------------------------------
44# GOOGLE
45# Maps API Key: https://console.cloud.google.com → APIs & Services → Credentials
46# Enable: Maps JavaScript API, Places API, Directions API, Geocoding API
47# OAuth: https://console.cloud.google.com → OAuth 2.0 Client IDs
48# ------------------------------------------------------------
49GOOGLE_MAPS_API_KEY=AIza...
50GOOGLE_CLIENT_ID=....apps.googleusercontent.com
51GOOGLE_CLIENT_SECRET=GOCSPX-...
52
53# ------------------------------------------------------------
54# GEOAPIFY (Distance/routing fallback)
55# https://www.geoapify.com/
56# ------------------------------------------------------------
57GEOAPIFY_API_KEY=...
58
59# ------------------------------------------------------------
60# SERVER
61# ------------------------------------------------------------
62PUBLIC_DOMAIN=https://bookaride.co.nz
63PORT=10000
64
65# Extra CORS origins (comma-separated), e.g. Vercel preview URLs
66# EXTRA_CORS_ORIGINS=https://my-preview.vercel.app
Modifiedbackend/app/core/auth.py+21−0View fileUnifiedSplit
@@ -9,6 +9,7 @@ from passlib.context import CryptContext
99from app.core.config import settings
1010
1111security = HTTPBearer()
12optional_security = HTTPBearer(auto_error=False)
1213pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
1314
1415
@@ -54,3 +55,23 @@ async def get_current_admin(
5455 if not admin:
5556 raise HTTPException(status_code=401, detail="Admin not found")
5657 return admin
58
59
60async def get_optional_admin(
61 credentials: Optional[HTTPAuthorizationCredentials] = Depends(optional_security),
62) -> Optional[dict]:
63 """Dependency: returns admin dict if a valid token is present, else None."""
64 if credentials is None:
65 return None
66 try:
67 token_data = decode_token(credentials.credentials)
68 except HTTPException:
69 return None
70 username = token_data.get("sub")
71 if not username:
72 return None
73
74 from app.main import db
75
76 admin = await db.admin_users.find_one({"username": username}, {"_id": 0})
77 return admin
Modifiedbackend/app/main.py+54−19View fileUnifiedSplit
@@ -25,8 +25,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
2525# CORS
2626app.add_middleware(
2727 CORSMiddleware,
28 allow_origins=settings.CORS_ORIGINS,
29 allow_origin_regex=r"https://.*\.vercel\.app",
28 allow_origins=["*"], # Allow all origins for now to avoid CORS issues
3029 allow_credentials=True,
3130 allow_methods=["*"],
3231 allow_headers=["*"],
@@ -35,6 +34,9 @@ app.add_middleware(
3534# Database handle
3635db: NeonDatabase = None # type: ignore
3736
37# Track startup errors for diagnostics
38_startup_errors: list[str] = []
39
3840
3941# ── Scheduler ─────────────────────────────────────────────────────
4042
@@ -77,17 +79,30 @@ async def _send_24h_reminders():
7779async def startup():
7880 global db
7981 if not settings.DATABASE_URL:
80 logger.error("DATABASE_URL not set — cannot connect to database")
82 msg = "DATABASE_URL not set — cannot connect to database"
83 logger.error(msg)
84 _startup_errors.append(msg)
85 return
86 try:
87 db = await NeonDatabase.connect(settings.DATABASE_URL, min_size=2, max_size=20)
88 logger.info("Connected to Neon PostgreSQL")
89 except Exception as e:
90 msg = f"Database connection failed: {e}"
91 logger.error(msg)
92 _startup_errors.append(msg)
8193 return
82 db = await NeonDatabase.connect(settings.DATABASE_URL, min_size=5, max_size=50)
83 logger.info("Connected to Neon PostgreSQL")
8494
8595 # Apply schema
86 schema_path = os.path.join(os.path.dirname(__file__), "..", "schema.sql")
87 if os.path.exists(schema_path):
88 async with db.pool.acquire() as conn:
89 await conn.execute(open(schema_path).read())
90 logger.info("Schema applied")
96 try:
97 schema_path = os.path.join(os.path.dirname(__file__), "..", "schema.sql")
98 if os.path.exists(schema_path):
99 async with db.pool.acquire() as conn:
100 await conn.execute(open(schema_path).read())
101 logger.info("Schema applied")
102 except Exception as e:
103 msg = f"Schema apply failed: {e}"
104 logger.error(msg)
105 _startup_errors.append(msg)
91106
92107 # Start APScheduler
93108 from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -110,12 +125,21 @@ async def shutdown():
110125
111126
112127async def root():
113 return {"status": "ok", "service": "bookaride-api", "version": "2.0.0"}
128 return {
129 "status": "ok",
130 "service": "bookaride-api",
131 "version": "2.0.1",
132 "routes_loaded": len(_route_modules),
133 "route_errors": _route_errors,
134 "startup_errors": _startup_errors,
135 }
114136
115137
116138
117139
118140async def health():
141 if db is None:
142 raise HTTPException(status_code=503, detail="Database not connected")
119143 try:
120144 await asyncio.wait_for(db.command("ping"), timeout=2.0)
121145 return {"status": "healthy", "database": "ok"}
@@ -125,12 +149,23 @@ async def health():
125149
126150# ── Routes ────────────────────────────────────────────────────────
127151
128from app.routes import auth, bookings, places, payments, drivers, admin, pricing
129152
130app.include_router(auth.router, prefix="/api")
131app.include_router(bookings.router, prefix="/api")
132app.include_router(places.router, prefix="/api")
133app.include_router(payments.router, prefix="/api")
134app.include_router(drivers.router, prefix="/api")
135app.include_router(admin.router, prefix="/api")
136app.include_router(pricing.router, prefix="/api")
153# ── Routes (with error handling per module) ──────────────────────
154
155_route_modules: list[str] = []
156_route_errors: list[str] = []
157
158ROUTE_MODULES = ["auth", "bookings", "places", "payments", "drivers", "admin", "pricing"]
159
160for _mod_name in ROUTE_MODULES:
161 try:
162 _mod = __import__(f"app.routes.{_mod_name}", fromlist=["router"])
163 _router = _mod.router
164 app.include_router(_router, prefix="/api")
165 app.include_router(_router)
166 _route_modules.append(_mod_name)
167 logger.info(f"Loaded route module: {_mod_name}")
168 except Exception as e:
169 error_msg = f"Failed to load route module '{_mod_name}': {e}\n{traceback.format_exc()}"
170 _route_errors.append(error_msg)
171 logger.error(error_msg)
Modifiedbackend/app/routes/admin.py+398−0View fileUnifiedSplit
@@ -1,11 +1,20 @@
11import asyncio
22import logging
3import uuid
34from datetime import datetime, timezone
45
56import pytz
67from fastapi import APIRouter, Depends, HTTPException
8from pydantic import BaseModel
9from typing import Optional
710
811from app.core.auth import get_current_admin
12from app.services.email import (
13 send_email,
14 send_booking_confirmation,
15 send_admin_notification,
16 log_email,
17)
918
1019router = APIRouter(prefix="/admin", tags=["Admin"])
1120logger = logging.getLogger(__name__)
@@ -78,3 +87,392 @@ async def list_customers(current_admin: dict = Depends(get_current_admin)):
7887 customers[email]["total_spent"] += b.get("pricing", {}).get("totalPrice", 0)
7988
8089 return {"customers": list(customers.values())}
90
91
92# ── Live Pricing (admin tool — no booking created) ──────────────
93
94
95class LivePriceRequest(BaseModel):
96 serviceType: str
97 pickupAddress: str
98 pickupAddresses: Optional[list] = []
99 dropoffAddress: str
100 passengers: int = 1
101 vipAirportPickup: bool = False
102 oversizedLuggage: bool = False
103 bookReturn: bool = False
104
105
106
107async def admin_live_pricing(
108 request: LivePriceRequest,
109 current_admin: dict = Depends(get_current_admin),
110):
111 """
112 Admin live pricing tool — calculates a price quote without creating a booking.
113 Used by admin staff to give customers quick price estimates.
114 """
115 from app.main import db
116 from app.routes.pricing import calculate_price, PriceRequest
117
118 price_request = PriceRequest(
119 serviceType=request.serviceType,
120 pickupAddress=request.pickupAddress,
121 pickupAddresses=request.pickupAddresses or [],
122 dropoffAddress=request.dropoffAddress,
123 passengers=request.passengers,
124 vipAirportPickup=request.vipAirportPickup,
125 oversizedLuggage=request.oversizedLuggage,
126 bookReturn=request.bookReturn,
127 )
128
129 result = await calculate_price(price_request)
130
131 # Log the pricing enquiry (not a booking)
132 await db.pricing_enquiries.insert_one({
133 "id": str(uuid.uuid4()),
134 "type": "live_pricing",
135 "admin": current_admin.get("username", "unknown"),
136 "request": request.dict(),
137 "result": result.dict(),
138 "createdAt": datetime.now(timezone.utc).isoformat(),
139 })
140
141 return {
142 "pricing": result.dict(),
143 "note": "This is a price estimate only — no booking has been created.",
144 }
145
146
147
148async def list_pricing_enquiries(
149 current_admin: dict = Depends(get_current_admin),
150):
151 """List recent pricing enquiries made through the admin live pricing tool."""
152 from app.main import db
153
154 enquiries = (
155 await db.pricing_enquiries.find({}, {"_id": 0})
156 .sort("createdAt", -1)
157 .to_list(100)
158 )
159 return {"enquiries": enquiries, "total": len(enquiries)}
160
161
162# ── Email Management ────────────────────────────────────────────
163
164
165class TestEmailRequest(BaseModel):
166 to: str
167 subject: Optional[str] = "BookARide — Test Email"
168 message: Optional[str] = "This is a test email from the BookARide admin panel."
169
170
171
172async def send_test_email(
173 data: TestEmailRequest,
174 current_admin: dict = Depends(get_current_admin),
175):
176 """Send a test email to verify the email delivery system."""
177 from app.main import db
178
179 html = f"""
180 <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
181 <div style="background: #1a1a2e; color: #d4a843; padding: 24px; text-align: center; border-radius: 12px 12px 0 0;">
182 <h1 style="margin: 0;">BookARide</h1>
183 <p style="color: #ccc; margin: 8px 0 0;">Email Test</p>
184 </div>
185 <div style="padding: 24px; background: #fff; border: 1px solid #eee; border-radius: 0 0 12px 12px;">
186 <p>{data.message}</p>
187 <hr style="border: none; border-top: 1px solid #eee; margin: 16px 0;">
188 <p style="font-size: 12px; color: #999;">
189 Sent by admin <strong>{current_admin.get('username', 'unknown')}</strong>
190 at {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}
191 </p>
192 </div>
193 </div>
194 """
195
196 result = await send_email(to=data.to, subject=data.subject, html=html)
197 await log_email(
198 db,
199 to=data.to,
200 subject=data.subject,
201 status="sent" if result.get("success") else "failed",
202 details={**result, "admin": current_admin.get("username")},
203 )
204
205 if result.get("success"):
206 return {"message": f"Test email sent to {data.to}", "details": result}
207 else:
208 raise HTTPException(
209 status_code=502,
210 detail=f"Email send failed: {result.get('error', 'Unknown error')}",
211 )
212
213
214
215async def resend_booking_confirmation(
216 booking_id: str,
217 current_admin: dict = Depends(get_current_admin),
218):
219 """Re-send booking confirmation email for a specific booking."""
220 from app.main import db
221
222 booking = await db.bookings.find_one({"id": booking_id}, {"_id": 0})
223 if not booking:
224 raise HTTPException(status_code=404, detail="Booking not found")
225
226 result = await send_booking_confirmation(db, booking)
227 if result.get("success"):
228 return {
229 "message": f"Confirmation email sent to {booking.get('email')}",
230 "details": result,
231 }
232 else:
233 raise HTTPException(
234 status_code=502,
235 detail=f"Email send failed: {result.get('error', 'Unknown error')}",
236 )
237
238
239
240async def get_email_logs(
241 current_admin: dict = Depends(get_current_admin),
242):
243 """View recent email send logs."""
244 from app.main import db
245
246 logs = (
247 await db.email_logs.find({}, {"_id": 0})
248 .sort("sentAt", -1)
249 .to_list(100)
250 )
251 return {"logs": logs, "total": len(logs)}
252
253
254# ── Booking lockdown (require confirmation for dangerous actions) ─
255
256
257# ── Admin Create Booking (on behalf of customer) ──────────────
258
259
260class AdminBookingCreate(BaseModel):
261 serviceType: str
262 pickupAddress: str
263 pickupAddresses: Optional[list] = []
264 dropoffAddress: str
265 date: str
266 time: str
267 passengers: str = "1"
268 name: str
269 email: str
270 phone: str
271 notes: Optional[str] = ""
272 departureFlightNumber: Optional[str] = ""
273 arrivalFlightNumber: Optional[str] = ""
274 bookReturn: Optional[bool] = False
275 returnDate: Optional[str] = ""
276 returnTime: Optional[str] = ""
277 returnFlightNumber: Optional[str] = ""
278 vipAirportPickup: Optional[bool] = False
279 oversizedLuggage: Optional[bool] = False
280 # Price override — if set, use this instead of calculated price
281 priceOverride: Optional[float] = None
282 sendConfirmation: Optional[bool] = True
283
284
285
286async def admin_create_booking(
287 data: AdminBookingCreate,
288 current_admin: dict = Depends(get_current_admin),
289):
290 """Create a booking on behalf of a customer. Optionally override the calculated price."""
291 from app.main import db
292 from app.routes.pricing import calculate_price, PriceRequest
293 from app.routes.bookings import get_next_reference_number
294
295 # Calculate price (or use override)
296 if data.priceOverride is not None and data.priceOverride > 0:
297 pricing = {
298 "totalPrice": round(data.priceOverride, 2),
299 "priceOverride": True,
300 "overrideBy": current_admin.get("username", "unknown"),
301 }
302 else:
303 price_req = PriceRequest(
304 serviceType=data.serviceType,
305 pickupAddress=data.pickupAddress,
306 pickupAddresses=data.pickupAddresses or [],
307 dropoffAddress=data.dropoffAddress,
308 passengers=int(data.passengers),
309 vipAirportPickup=data.vipAirportPickup,
310 oversizedLuggage=data.oversizedLuggage,
311 bookReturn=data.bookReturn,
312 )
313 result = await calculate_price(price_req)
314 pricing = result.dict()
315
316 # Build booking
317 ref_number = await get_next_reference_number()
318 booking_id = str(uuid.uuid4())
319
320 booking_dict = {
321 "id": booking_id,
322 "serviceType": data.serviceType,
323 "pickupAddress": data.pickupAddress,
324 "pickupAddresses": data.pickupAddresses or [],
325 "dropoffAddress": data.dropoffAddress,
326 "date": data.date,
327 "time": data.time,
328 "passengers": data.passengers,
329 "name": data.name,
330 "email": data.email,
331 "phone": data.phone,
332 "notes": data.notes or "",
333 "departureFlightNumber": data.departureFlightNumber or "",
334 "arrivalFlightNumber": data.arrivalFlightNumber or "",
335 "bookReturn": data.bookReturn,
336 "returnDate": data.returnDate or "",
337 "returnTime": data.returnTime or "",
338 "returnFlightNumber": data.returnFlightNumber or "",
339 "vipAirportPickup": data.vipAirportPickup,
340 "oversizedLuggage": data.oversizedLuggage,
341 "pricing": pricing,
342 "totalPrice": pricing.get("totalPrice", 0),
343 "referenceNumber": str(ref_number),
344 "status": "confirmed",
345 "payment_status": "unpaid",
346 "createdAt": datetime.now(timezone.utc).isoformat(),
347 "createdBy": current_admin.get("username", "admin"),
348 "source": "admin",
349 }
350
351 await db.bookings.insert_one(booking_dict)
352 logger.info(
353 f"Admin {current_admin.get('username')} created booking #{ref_number} for {data.name}"
354 )
355
356 # Send confirmation email to customer + admin notification
357 if data.sendConfirmation:
358 await send_booking_confirmation(db, booking_dict)
359 await send_admin_notification(db, booking_dict)
360
361 return {
362 "message": f"Booking #{ref_number} created for {data.name}",
363 "booking": booking_dict,
364 }
365
366
367# ── Price Override on existing booking ─────────────────────────
368
369
370class PriceOverrideRequest(BaseModel):
371 totalPrice: float
372 reason: Optional[str] = ""
373
374
375
376async def override_booking_price(
377 booking_id: str,
378 data: PriceOverrideRequest,
379 current_admin: dict = Depends(get_current_admin),
380):
381 """Override the price on an existing booking."""
382 from app.main import db
383
384 booking = await db.bookings.find_one({"id": booking_id}, {"_id": 0})
385 if not booking:
386 raise HTTPException(status_code=404, detail="Booking not found")
387
388 old_price = booking.get("totalPrice", 0)
389 new_pricing = booking.get("pricing", {})
390 new_pricing["originalTotalPrice"] = old_price
391 new_pricing["totalPrice"] = round(data.totalPrice, 2)
392 new_pricing["priceOverride"] = True
393 new_pricing["overrideBy"] = current_admin.get("username", "unknown")
394 new_pricing["overrideReason"] = data.reason or ""
395 new_pricing["overrideAt"] = datetime.now(timezone.utc).isoformat()
396
397 await db.bookings.update_one(
398 {"id": booking_id},
399 {"$set": {
400 "pricing": new_pricing,
401 "totalPrice": round(data.totalPrice, 2),
402 }},
403 )
404
405 logger.info(
406 f"Admin {current_admin.get('username')} overrode price on {booking_id}: "
407 f"${old_price} -> ${data.totalPrice} ({data.reason})"
408 )
409
410 return {
411 "message": f"Price updated from ${old_price:.2f} to ${data.totalPrice:.2f}",
412 "old_price": old_price,
413 "new_price": round(data.totalPrice, 2),
414 }
415
416
417
418async def confirm_booking(
419 booking_id: str,
420 current_admin: dict = Depends(get_current_admin),
421):
422 """Confirm a pending booking and send confirmation email."""
423 from app.main import db
424
425 booking = await db.bookings.find_one({"id": booking_id}, {"_id": 0})
426 if not booking:
427 raise HTTPException(status_code=404, detail="Booking not found")
428
429 if booking.get("status") == "confirmed":
430 return {"message": "Booking is already confirmed"}
431
432 await db.bookings.update_one(
433 {"id": booking_id},
434 {"$set": {
435 "status": "confirmed",
436 "confirmedAt": datetime.now(timezone.utc).isoformat(),
437 "confirmedBy": current_admin.get("username"),
438 }},
439 )
440
441 # Send confirmation email
442 updated_booking = await db.bookings.find_one({"id": booking_id}, {"_id": 0})
443 email_result = await send_booking_confirmation(db, updated_booking)
444
445 return {
446 "message": "Booking confirmed",
447 "email_sent": email_result.get("success", False),
448 }
449
450
451
452async def cancel_booking(
453 booking_id: str,
454 reason: dict = None,
455 current_admin: dict = Depends(get_current_admin),
456):
457 """Cancel a booking. Requires admin authentication."""
458 from app.main import db
459
460 booking = await db.bookings.find_one({"id": booking_id}, {"_id": 0})
461 if not booking:
462 raise HTTPException(status_code=404, detail="Booking not found")
463
464 if booking.get("status") == "cancelled":
465 return {"message": "Booking is already cancelled"}
466
467 cancel_reason = (reason or {}).get("reason", "Cancelled by admin")
468 await db.bookings.update_one(
469 {"id": booking_id},
470 {"$set": {
471 "status": "cancelled",
472 "cancelledAt": datetime.now(timezone.utc).isoformat(),
473 "cancelledBy": current_admin.get("username"),
474 "cancelReason": cancel_reason,
475 }},
476 )
477
478 return {"message": "Booking cancelled", "reason": cancel_reason}
Modifiedbackend/app/routes/auth.py+102−2View fileUnifiedSplit
@@ -1,10 +1,14 @@
11import uuid
22from datetime import datetime, timezone
3from typing import Optional
34
4from fastapi import APIRouter, HTTPException
5from fastapi import APIRouter, Depends, HTTPException
6from pydantic import BaseModel
57
68from app.core.auth import (
79 create_access_token,
10 get_current_admin,
11 get_optional_admin,
812 hash_password,
913 verify_password,
1014)
@@ -14,9 +18,28 @@ router = APIRouter(prefix="/admin", tags=["Auth"])
1418
1519
1620
17async def register(data: AdminRegister):
21async def register(
22 data: AdminRegister,
23 current_admin: Optional[dict] = Depends(get_optional_admin),
24):
25 """
26 Register a new admin user.
27 - First admin can register without auth (bootstrap).
28 - After that, only existing admins can create new accounts.
29 """
1830 from app.main import db
1931
32 # Check if any admins exist
33 admin_count = await db.admin_users.count_documents({})
34
35 if admin_count > 0:
36 # Must be authenticated to register new admins
37 if current_admin is None:
38 raise HTTPException(
39 status_code=403,
40 detail="Registration is restricted. Only existing admins can create new accounts.",
41 )
42
2043 existing = await db.admin_users.find_one({"username": data.username})
2144 if existing:
2245 raise HTTPException(status_code=400, detail="Username already exists")
@@ -57,3 +80,80 @@ async def login(data: AdminLogin):
5780 "username": admin["username"],
5881 "email": admin.get("email"),
5982 }
83
84
85# ── Google OAuth ───────────────────────────────────────────────
86
87
88class GoogleAuthRequest(BaseModel):
89 credential: str # Google ID token from frontend
90
91
92
93async def google_auth(data: GoogleAuthRequest):
94 """
95 Authenticate via Google Sign-In.
96 Verifies the Google ID token and creates/finds an admin account.
97 Only allows sign-in for emails in the allowed list (or any @bookaride.co.nz email).
98 """
99 from app.main import db
100 from app.core.config import settings
101
102 # Verify the Google ID token
103 try:
104 from google.oauth2 import id_token
105 from google.auth.transport import requests as google_requests
106
107 idinfo = id_token.verify_oauth2_token(
108 data.credential,
109 google_requests.Request(),
110 settings.GOOGLE_CLIENT_ID,
111 )
112 except Exception as e:
113 raise HTTPException(status_code=401, detail=f"Invalid Google token: {e}")
114
115 email = idinfo.get("email", "").lower()
116 name = idinfo.get("name", "")
117
118 if not email:
119 raise HTTPException(status_code=401, detail="No email in Google token")
120
121 # Allow any @bookaride.co.nz email, or check for existing admin account
122 is_bookaride_email = email.endswith("@bookaride.co.nz")
123 existing_admin = await db.admin_users.find_one({"email": email}, {"_id": 0})
124
125 if not is_bookaride_email and not existing_admin:
126 raise HTTPException(
127 status_code=403,
128 detail="Access denied. Only authorized emails can sign in.",
129 )
130
131 # Create admin account if it doesn't exist (auto-provision for @bookaride.co.nz)
132 if not existing_admin:
133 username = email.split("@")[0]
134 # Ensure unique username
135 base_username = username
136 counter = 1
137 while await db.admin_users.find_one({"username": username}):
138 username = f"{base_username}{counter}"
139 counter += 1
140
141 existing_admin = {
142 "id": str(uuid.uuid4()),
143 "username": username,
144 "email": email,
145 "hashed_password": "", # No password for Google-only accounts
146 "google_name": name,
147 "created_at": datetime.now(timezone.utc).isoformat(),
148 "is_active": True,
149 "auth_method": "google",
150 }
151 await db.admin_users.insert_one(existing_admin)
152
153 token = create_access_token({"sub": existing_admin["username"]})
154 return {
155 "access_token": token,
156 "token_type": "bearer",
157 "username": existing_admin["username"],
158 "email": existing_admin.get("email"),
159 }
Modifiedbackend/app/routes/bookings.py+7−0View fileUnifiedSplit
@@ -52,6 +52,13 @@ async def create_booking(booking: BookingCreate, background_tasks: BackgroundTas
5252 raise HTTPException(status_code=500, detail="Failed to save booking")
5353
5454 logger.info(f"Booking created: {booking_obj.id} ref #{ref_number}")
55
56 # Send confirmation emails in background
57 from app.services.email import send_booking_confirmation, send_admin_notification
58
59 background_tasks.add_task(send_booking_confirmation, db, booking_dict)
60 background_tasks.add_task(send_admin_notification, db, booking_dict)
61
5562 return booking_dict
5663
5764
Modifiedbackend/app/routes/places.py+47−16View fileUnifiedSplit
@@ -1,34 +1,45 @@
1import os
1import logging
22import httpx
33from fastapi import APIRouter, HTTPException
44
55from app.core.config import settings
66
77router = APIRouter(prefix="/places", tags=["Places"])
8logger = logging.getLogger(__name__)
89
910
1011
1112async def autocomplete(input: str, sessiontoken: str = ""):
1213 api_key = settings.GOOGLE_MAPS_API_KEY
1314 if not api_key:
15 logger.error("GOOGLE_MAPS_API_KEY is not set")
1416 raise HTTPException(status_code=500, detail="Google Maps API key not configured")
1517
16 async with httpx.AsyncClient() as client:
17 resp = await client.post(
18 "https://places.googleapis.com/v1/places:autocomplete",
19 json={
20 "input": input,
21 "includedRegionCodes": ["nz"],
22 "languageCode": "en",
23 },
24 headers={
25 "X-Goog-Api-Key": api_key,
26 "X-Goog-FieldMask": "suggestions.placePrediction.text,suggestions.placePrediction.placeId",
27 },
28 timeout=5.0,
29 )
18 body = {
19 "input": input,
20 "includedRegionCodes": ["nz"],
21 "languageCode": "en",
22 }
23 if sessiontoken:
24 body["sessionToken"] = sessiontoken
25
26 try:
27 async with httpx.AsyncClient() as client:
28 resp = await client.post(
29 "https://places.googleapis.com/v1/places:autocomplete",
30 json=body,
31 headers={
32 "X-Goog-Api-Key": api_key,
33 "X-Goog-FieldMask": "suggestions.placePrediction.text,suggestions.placePrediction.placeId",
34 },
35 timeout=5.0,
36 )
37 except httpx.RequestError as e:
38 logger.error(f"Places API request failed: {e}")
39 raise HTTPException(status_code=502, detail="Could not reach Google Places API")
3040
3141 if resp.status_code != 200:
42 logger.error(f"Places API returned {resp.status_code}: {resp.text}")
3243 raise HTTPException(status_code=resp.status_code, detail="Places API error")
3344
3445 data = resp.json()
@@ -46,5 +57,25 @@ async def autocomplete(input: str, sessiontoken: str = ""):
4657
4758
4859async def test_places():
60 """Quick check: is the API key configured and does it work?"""
4961 api_key = settings.GOOGLE_MAPS_API_KEY
50 return {"configured": bool(api_key)}
62 if not api_key:
63 return {"configured": False, "error": "GOOGLE_MAPS_API_KEY not set"}
64
65 try:
66 async with httpx.AsyncClient() as client:
67 resp = await client.post(
68 "https://places.googleapis.com/v1/places:autocomplete",
69 json={"input": "Auckland Airport", "includedRegionCodes": ["nz"]},
70 headers={
71 "X-Goog-Api-Key": api_key,
72 "X-Goog-FieldMask": "suggestions.placePrediction.text",
73 },
74 timeout=5.0,
75 )
76 if resp.status_code == 200:
77 return {"configured": True, "status": "working"}
78 else:
79 return {"configured": True, "status": "error", "code": resp.status_code, "detail": resp.text}
80 except Exception as e:
81 return {"configured": True, "status": "error", "detail": str(e)}
Modifiedbackend/schema.sql+14−0View fileUnifiedSplit
@@ -343,6 +343,20 @@ CREATE TABLE IF NOT EXISTS return_alerts_sent (
343343
344344CREATE INDEX IF NOT EXISTS idx_return_alerts_key ON return_alerts_sent ((data->>'alert_key'));
345345
346-- ============================================================
347-- PRICING ENQUIRIES (admin live pricing tool — no booking)
348-- ============================================================
349
350CREATE TABLE IF NOT EXISTS pricing_enquiries (
351 _id BIGSERIAL PRIMARY KEY,
352 id TEXT UNIQUE,
353 data JSONB NOT NULL DEFAULT '{}',
354 created_at TIMESTAMPTZ DEFAULT NOW()
355);
356
357CREATE INDEX IF NOT EXISTS idx_pricing_enquiries_data ON pricing_enquiries USING GIN (data);
358CREATE INDEX IF NOT EXISTS idx_pricing_enquiries_admin ON pricing_enquiries ((data->>'admin'));
359
346360-- ============================================================
347361-- HELPER: Auto-create table for unknown collections
348362-- This function is called by the compatibility layer when
Addedfrontend/.env.example+12−0View fileUnifiedSplit
@@ -0,0 +1,12 @@
1# ============================================================
2# BookARide V2 Frontend — Environment Variables
3# Copy to .env.local for local dev, or set in Vercel dashboard.
4# ============================================================
5
6# Backend API URL
7# Local dev: http://localhost:10000
8# Production: https://api.bookaride.co.nz (or your Render service URL)
9VITE_API_URL=http://localhost:10000
10
11# Google Maps API Key (same key as backend, or a browser-restricted one)
12VITE_GOOGLE_MAPS_API_KEY=AIza...
Modifiedfrontend/src/App.jsx+1−1View fileUnifiedSplit
@@ -40,7 +40,7 @@ export default function App() {
4040 <Route path="drive-with-us" element={<Navigate to="/contact" replace />} />
4141 <Route path="travel-agents" element={<Navigate to="/contact" replace />} />
4242
43 {/* Catch-all */}
43 {/* Catch-all (exclude admin) */}
4444 <Route path="*" element={<NotFound />} />
4545 </Route>
4646
Addedfrontend/src/components/admin/AdminBookings.jsx+487−0View fileUnifiedSplit
@@ -0,0 +1,487 @@
1import { useState, useEffect } from 'react'
2import {
3 Loader2,
4 Search,
5 CheckCircle,
6 XCircle,
7 Clock,
8 Trash2,
9 Mail,
10 ChevronDown,
11 ChevronUp,
12 AlertTriangle,
13 Lock,
14 DollarSign,
15 PlusCircle,
16} from 'lucide-react'
17import { Link } from 'react-router-dom'
18import api from '../../lib/api'
19
20const STATUS_BADGES = {
21 pending: { bg: 'bg-yellow-100', text: 'text-yellow-800', icon: Clock },
22 confirmed: { bg: 'bg-green-100', text: 'text-green-800', icon: CheckCircle },
23 completed: { bg: 'bg-blue-100', text: 'text-blue-800', icon: CheckCircle },
24 cancelled: { bg: 'bg-red-100', text: 'text-red-800', icon: XCircle },
25}
26
27export default function AdminBookings() {
28 const [bookings, setBookings] = useState([])
29 const [loading, setLoading] = useState(true)
30 const [search, setSearch] = useState('')
31 const [statusFilter, setStatusFilter] = useState('')
32 const [expanded, setExpanded] = useState(null)
33 const [actionLoading, setActionLoading] = useState(null)
34 const [confirmDialog, setConfirmDialog] = useState(null)
35 const [message, setMessage] = useState(null)
36 const [priceOverride, setPriceOverride] = useState({ id: null, value: '', reason: '' })
37
38 async function fetchBookings() {
39 setLoading(true)
40 try {
41 const params = statusFilter ? `?status=${statusFilter}` : ''
42 const { data } = await api.get(`/bookings${params}`)
43 setBookings(data.bookings || [])
44 } catch {
45 setBookings([])
46 } finally {
47 setLoading(false)
48 }
49 }
50
51 useEffect(() => {
52 fetchBookings()
53 }, [statusFilter])
54
55 function showMessage(text, type = 'success') {
56 setMessage({ text, type })
57 setTimeout(() => setMessage(null), 4000)
58 }
59
60 async function confirmBooking(id) {
61 setActionLoading(id)
62 try {
63 await api.post(`/admin/bookings/${id}/confirm`)
64 showMessage('Booking confirmed and confirmation email sent')
65 fetchBookings()
66 } catch (err) {
67 showMessage(err.response?.data?.detail || 'Failed to confirm', 'error')
68 } finally {
69 setActionLoading(null)
70 setConfirmDialog(null)
71 }
72 }
73
74 async function cancelBooking(id) {
75 setActionLoading(id)
76 try {
77 await api.post(`/admin/bookings/${id}/cancel`, { reason: 'Cancelled by admin' })
78 showMessage('Booking cancelled')
79 fetchBookings()
80 } catch (err) {
81 showMessage(err.response?.data?.detail || 'Failed to cancel', 'error')
82 } finally {
83 setActionLoading(null)
84 setConfirmDialog(null)
85 }
86 }
87
88 async function deleteBooking(id) {
89 setActionLoading(id)
90 try {
91 await api.delete(`/bookings/${id}`)
92 showMessage('Booking archived and removed')
93 fetchBookings()
94 } catch (err) {
95 showMessage(err.response?.data?.detail || 'Failed to delete', 'error')
96 } finally {
97 setActionLoading(null)
98 setConfirmDialog(null)
99 }
100 }
101
102 async function resendConfirmation(id) {
103 setActionLoading(id)
104 try {
105 const { data } = await api.post(`/admin/email/send-confirmation/${id}`)
106 showMessage(data.message || 'Email sent')
107 } catch (err) {
108 showMessage(err.response?.data?.detail || 'Failed to send email', 'error')
109 } finally {
110 setActionLoading(null)
111 }
112 }
113
114 async function submitPriceOverride(id) {
115 if (!priceOverride.value || parseFloat(priceOverride.value) <= 0) {
116 showMessage('Please enter a valid price', 'error')
117 return
118 }
119 setActionLoading(id)
120 try {
121 const { data } = await api.patch(`/admin/bookings/${id}/price-override`, {
122 totalPrice: parseFloat(priceOverride.value),
123 reason: priceOverride.reason,
124 })
125 showMessage(data.message || 'Price updated')
126 setPriceOverride({ id: null, value: '', reason: '' })
127 fetchBookings()
128 } catch (err) {
129 showMessage(err.response?.data?.detail || 'Failed to update price', 'error')
130 } finally {
131 setActionLoading(null)
132 }
133 }
134
135 const filtered = bookings.filter((b) => {
136 if (!search) return true
137 const q = search.toLowerCase()
138 return (
139 (b.name || '').toLowerCase().includes(q) ||
140 (b.email || '').toLowerCase().includes(q) ||
141 (b.referenceNumber || '').includes(q) ||
142 (b.pickupAddress || '').toLowerCase().includes(q) ||
143 (b.dropoffAddress || '').toLowerCase().includes(q)
144 )
145 })
146
147 return (
148 <div className="space-y-4">
149 {/* Message banner */}
150 {message && (
151 <div className={`p-3 rounded-lg text-sm font-medium ${
152 message.type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' : 'bg-green-50 text-green-700 border border-green-200'
153 }`}>
154 {message.text}
155 </div>
156 )}
157
158 {/* Confirm dialog */}
159 {confirmDialog && (
160 <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
161 <div className="bg-white rounded-xl p-6 max-w-md w-full shadow-2xl">
162 <div className="flex items-center gap-3 mb-4">
163 <div className={`w-10 h-10 rounded-full flex items-center justify-center ${
164 confirmDialog.type === 'delete' ? 'bg-red-100' : confirmDialog.type === 'cancel' ? 'bg-yellow-100' : 'bg-green-100'
165 }`}>
166 {confirmDialog.type === 'delete' ? (
167 <Trash2 className="w-5 h-5 text-red-600" />
168 ) : confirmDialog.type === 'cancel' ? (
169 <AlertTriangle className="w-5 h-5 text-yellow-600" />
170 ) : (
171 <CheckCircle className="w-5 h-5 text-green-600" />
172 )}
173 </div>
174 <div>
175 <h3 className="font-semibold text-gray-900">{confirmDialog.title}</h3>
176 <p className="text-sm text-gray-500">{confirmDialog.message}</p>
177 </div>
178 </div>
179 <div className="flex gap-3 mt-6">
180 <button
181 onClick={() => setConfirmDialog(null)}
182 className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50"
183 >
184 Cancel
185 </button>
186 <button
187 onClick={confirmDialog.onConfirm}
188 disabled={actionLoading}
189 className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium text-white disabled:opacity-50 ${
190 confirmDialog.type === 'delete' ? 'bg-red-600 hover:bg-red-700'
191 : confirmDialog.type === 'cancel' ? 'bg-yellow-600 hover:bg-yellow-700'
192 : 'bg-green-600 hover:bg-green-700'
193 }`}
194 >
195 {actionLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : 'Confirm'}
196 </button>
197 </div>
198 </div>
199 </div>
200 )}
201
202 {/* Filters */}
203 <div className="flex flex-col sm:flex-row gap-3">
204 <Link
205 to="/admin/create-booking"
206 className="px-4 py-2.5 bg-[#d4a843] text-white rounded-lg text-sm font-medium hover:bg-[#c49a3a] transition-colors flex items-center gap-1.5 shrink-0"
207 >
208 <PlusCircle className="w-4 h-4" /> New Booking
209 </Link>
210 <div className="relative flex-1">
211 <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
212 <input
213 type="text"
214 value={search}
215 onChange={(e) => setSearch(e.target.value)}
216 placeholder="Search bookings by name, email, reference..."
217 className="w-full pl-10 pr-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
218 />
219 </div>
220 <select
221 value={statusFilter}
222 onChange={(e) => setStatusFilter(e.target.value)}
223 className="px-4 py-2.5 border border-gray-300 rounded-lg text-sm bg-white"
224 >
225 <option value="">All statuses</option>
226 <option value="pending">Pending</option>
227 <option value="confirmed">Confirmed</option>
228 <option value="completed">Completed</option>
229 <option value="cancelled">Cancelled</option>
230 </select>
231 </div>
232
233 {/* Lock notice */}
234 <div className="bg-blue-50 border border-blue-200 rounded-lg p-3 flex items-center gap-2 text-sm text-blue-700">
235 <Lock className="w-4 h-4 shrink-0" />
236 Bookings are protected. All destructive actions (cancel, delete) require confirmation.
237 </div>
238
239 {/* Bookings list */}
240 {loading ? (
241 <div className="flex items-center justify-center h-48">
242 <Loader2 className="w-8 h-8 animate-spin text-gray-400" />
243 </div>
244 ) : filtered.length === 0 ? (
245 <div className="text-center py-12 text-gray-400">No bookings found</div>
246 ) : (
247 <div className="space-y-3">
248 {filtered.map((b) => {
249 const badge = STATUS_BADGES[b.status] || STATUS_BADGES.pending
250 const isExpanded = expanded === b.id
251 const pricing = b.pricing || {}
252
253 return (
254 <div key={b.id} className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
255 {/* Row header */}
256 <button
257 onClick={() => setExpanded(isExpanded ? null : b.id)}
258 className="w-full px-5 py-4 flex items-center gap-4 text-left hover:bg-gray-50 transition-colors"
259 >
260 <div className="flex-1 min-w-0">
261 <div className="flex items-center gap-2 mb-1">
262 <span className="text-sm font-bold text-gray-900">#{b.referenceNumber || '—'}</span>
263 <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${badge.bg} ${badge.text}`}>
264 <badge.icon className="w-3 h-3" />
265 {(b.status || 'pending').charAt(0).toUpperCase() + (b.status || 'pending').slice(1)}
266 </span>
267 {b.payment_status === 'paid' && (
268 <span className="px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">Paid</span>
269 )}
270 </div>
271 <div className="text-sm text-gray-600 truncate">{b.name} — {b.pickupAddress} → {b.dropoffAddress}</div>
272 <div className="text-xs text-gray-400 mt-0.5">{b.date} at {b.time} · {b.email}</div>
273 </div>
274 <div className="text-right shrink-0">
275 <div className="text-lg font-bold text-gray-900">${(pricing.totalPrice || b.totalPrice || 0).toFixed(2)}</div>
276 <div className="text-xs text-gray-400">NZD</div>
277 </div>
278 {isExpanded ? <ChevronUp className="w-5 h-5 text-gray-400" /> : <ChevronDown className="w-5 h-5 text-gray-400" />}
279 </button>
280
281 {/* Expanded details */}
282 {isExpanded && (
283 <div className="px-5 pb-5 border-t border-gray-100">
284 <div className="grid sm:grid-cols-2 gap-4 mt-4 text-sm">
285 <div>
286 <span className="text-gray-400">Service:</span>{' '}
287 <span className="text-gray-700">{b.serviceType}</span>
288 </div>
289 <div>
290 <span className="text-gray-400">Passengers:</span>{' '}
291 <span className="text-gray-700">{b.passengers}</span>
292 </div>
293 <div>
294 <span className="text-gray-400">Phone:</span>{' '}
295 <span className="text-gray-700">{b.phone}</span>
296 </div>
297 <div>
298 <span className="text-gray-400">Payment:</span>{' '}
299 <span className="text-gray-700">{b.payment_status || 'unpaid'}</span>
300 </div>
301 {b.departureFlightNumber && (
302 <div>
303 <span className="text-gray-400">Departure Flight:</span>{' '}
304 <span className="text-gray-700">{b.departureFlightNumber}</span>
305 </div>
306 )}
307 {b.arrivalFlightNumber && (
308 <div>
309 <span className="text-gray-400">Arrival Flight:</span>{' '}
310 <span className="text-gray-700">{b.arrivalFlightNumber}</span>
311 </div>
312 )}
313 {b.bookReturn && (
314 <>
315 <div>
316 <span className="text-gray-400">Return Date:</span>{' '}
317 <span className="text-gray-700">{b.returnDate} at {b.returnTime}</span>
318 </div>
319 {b.returnFlightNumber && (
320 <div>
321 <span className="text-gray-400">Return Flight:</span>{' '}
322 <span className="text-gray-700">{b.returnFlightNumber}</span>
323 </div>
324 )}
325 </>
326 )}
327 {b.notes && (
328 <div className="sm:col-span-2">
329 <span className="text-gray-400">Notes:</span>{' '}
330 <span className="text-gray-700">{b.notes}</span>
331 </div>
332 )}
333
334 {/* Price breakdown */}
335 {pricing.basePrice && (
336 <div className="sm:col-span-2 bg-gray-50 rounded-lg p-3 space-y-1">
337 <div className="text-xs font-semibold text-gray-500 mb-2">PRICE BREAKDOWN</div>
338 <div className="flex justify-between text-sm">
339 <span className="text-gray-500">Base ({pricing.distance?.toFixed(1)} km)</span>
340 <span>${pricing.basePrice?.toFixed(2)}</span>
341 </div>
342 {pricing.airportFee > 0 && (
343 <div className="flex justify-between text-sm">
344 <span className="text-gray-500">VIP Airport</span>
345 <span>${pricing.airportFee?.toFixed(2)}</span>
346 </div>
347 )}
348 {pricing.oversizedLuggageFee > 0 && (
349 <div className="flex justify-between text-sm">
350 <span className="text-gray-500">Oversized Luggage</span>
351 <span>${pricing.oversizedLuggageFee?.toFixed(2)}</span>
352 </div>
353 )}
354 {pricing.passengerFee > 0 && (
355 <div className="flex justify-between text-sm">
356 <span className="text-gray-500">Extra Passengers</span>
357 <span>${pricing.passengerFee?.toFixed(2)}</span>
358 </div>
359 )}
360 <div className="flex justify-between text-sm border-t border-gray-200 pt-1 mt-1">
361 <span className="text-gray-500">Stripe Fee</span>
362 <span>${pricing.stripeFee?.toFixed(2)}</span>
363 </div>
364 <div className="flex justify-between font-bold text-sm border-t border-gray-200 pt-1 mt-1">
365 <span>Total</span>
366 <span className="text-[#d4a843]">${pricing.totalPrice?.toFixed(2)} NZD</span>
367 </div>
368 </div>
369 )}
370 </div>
371
372 {/* Price override indicator */}
373 {pricing.priceOverride && (
374 <div className="sm:col-span-2 mt-2 flex items-center gap-1.5 text-xs text-amber-600 font-medium">
375 <AlertTriangle className="w-3.5 h-3.5" />
376 Price manually overridden by {pricing.overrideBy || 'admin'}
377 {pricing.originalTotalPrice && <span> (was ${pricing.originalTotalPrice.toFixed(2)})</span>}
378 </div>
379 )}
380
381 {/* Price override form */}
382 {priceOverride.id === b.id ? (
383 <div className="sm:col-span-2 bg-amber-50 border border-amber-200 rounded-lg p-3 mt-2 space-y-2">
384 <div className="text-xs font-semibold text-amber-800">OVERRIDE PRICE</div>
385 <div className="flex gap-2">
386 <div className="relative flex-1">
387 <span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
388 <input
389 type="number"
390 step="0.01"
391 min="0"
392 value={priceOverride.value}
393 onChange={(e) => setPriceOverride({ ...priceOverride, value: e.target.value })}
394 placeholder="New price"
395 className="w-full pl-7 pr-2 py-2 border border-gray-300 rounded-lg text-sm"
396 />
397 </div>
398 <input
399 type="text"
400 value={priceOverride.reason}
401 onChange={(e) => setPriceOverride({ ...priceOverride, reason: e.target.value })}
402 placeholder="Reason (optional)"
403 className="flex-1 px-2 py-2 border border-gray-300 rounded-lg text-sm"
404 />
405 </div>
406 <div className="flex gap-2">
407 <button
408 onClick={() => submitPriceOverride(b.id)}
409 disabled={actionLoading === b.id}
410 className="px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-medium hover:bg-amber-700 disabled:opacity-50"
411 >
412 {actionLoading === b.id ? 'Saving...' : 'Save Price'}
413 </button>
414 <button
415 onClick={() => setPriceOverride({ id: null, value: '', reason: '' })}
416 className="px-3 py-1.5 border border-gray-300 rounded-lg text-xs font-medium text-gray-600 hover:bg-gray-50"
417 >
418 Cancel
419 </button>
420 </div>
421 </div>
422 ) : null}
423
424 {/* Actions */}
425 <div className="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-100">
426 {b.status === 'pending' && (
427 <button
428 onClick={() => setConfirmDialog({
429 title: 'Confirm Booking',
430 message: `Confirm booking #${b.referenceNumber} for ${b.name}? A confirmation email will be sent.`,
431 type: 'confirm',
432 onConfirm: () => confirmBooking(b.id),
433 })}
434 className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm font-medium hover:bg-green-700 transition-colors flex items-center gap-1.5"
435 >
436 <CheckCircle className="w-4 h-4" /> Confirm
437 </button>
438 )}
439 <button
440 onClick={() => resendConfirmation(b.id)}
441 disabled={actionLoading === b.id}
442 className="px-4 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors flex items-center gap-1.5 disabled:opacity-50"
443 >
444 {actionLoading === b.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Mail className="w-4 h-4" />}
445 Resend Email
446 </button>
447 <button
448 onClick={() => setPriceOverride({ id: b.id, value: (pricing.totalPrice || b.totalPrice || 0).toFixed(2), reason: '' })}
449 className="px-4 py-2 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors flex items-center gap-1.5"
450 >
451 <DollarSign className="w-4 h-4" /> Override Price
452 </button>
453 {b.status !== 'cancelled' && (
454 <button
455 onClick={() => setConfirmDialog({
456 title: 'Cancel Booking',
457 message: `Are you sure you want to cancel booking #${b.referenceNumber}? This action can be undone by re-confirming.`,
458 type: 'cancel',
459 onConfirm: () => cancelBooking(b.id),
460 })}
461 className="px-4 py-2 bg-yellow-50 text-yellow-700 rounded-lg text-sm font-medium hover:bg-yellow-100 transition-colors flex items-center gap-1.5"
462 >
463 <XCircle className="w-4 h-4" /> Cancel
464 </button>
465 )}
466 <button
467 onClick={() => setConfirmDialog({
468 title: 'Delete Booking',
469 message: `Permanently delete booking #${b.referenceNumber}? It will be archived but removed from the active bookings list. This cannot be undone.`,
470 type: 'delete',
471 onConfirm: () => deleteBooking(b.id),
472 })}
473 className="px-4 py-2 bg-red-50 text-red-700 rounded-lg text-sm font-medium hover:bg-red-100 transition-colors flex items-center gap-1.5"
474 >
475 <Trash2 className="w-4 h-4" /> Delete
476 </button>
477 </div>
478 </div>
479 )}
480 </div>
481 )
482 })}
483 </div>
484 )}
485 </div>
486 )
487}
Addedfrontend/src/components/admin/AdminCreateBooking.jsx+582−0View fileUnifiedSplit
@@ -0,0 +1,582 @@
1import { useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import {
4 Plane,
5 MapPin,
6 Users,
7 Loader2,
8 DollarSign,
9 Star,
10 Luggage,
11 RotateCcw,
12 Send,
13 Plus,
14 X,
15 CheckCircle,
16 AlertTriangle,
17 Calendar,
18 Clock,
19 User,
20 Mail,
21 Phone,
22} from 'lucide-react'
23import api from '../../lib/api'
24import AddressInput from '../booking/AddressInput'
25
26const SERVICE_TYPES = [
27 { id: 'airport-transfer', label: 'Airport Transfer', icon: Plane },
28 { id: 'point-to-point', label: 'Point to Point', icon: MapPin },
29]
30
31export default function AdminCreateBooking() {
32 const navigate = useNavigate()
33 const [loading, setLoading] = useState(false)
34 const [pricingLoading, setPricingLoading] = useState(false)
35 const [error, setError] = useState('')
36 const [success, setSuccess] = useState(null)
37 const [calculatedPrice, setCalculatedPrice] = useState(null)
38
39 const [form, setForm] = useState({
40 serviceType: 'airport-transfer',
41 pickupAddress: '',
42 pickupAddresses: [],
43 dropoffAddress: '',
44 date: '',
45 time: '',
46 passengers: '1',
47 name: '',
48 email: '',
49 phone: '',
50 notes: '',
51 departureFlightNumber: '',
52 arrivalFlightNumber: '',
53 bookReturn: false,
54 returnDate: '',
55 returnTime: '',
56 returnFlightNumber: '',
57 vipAirportPickup: false,
58 oversizedLuggage: false,
59 priceOverride: '',
60 sendConfirmation: true,
61 })
62
63 function update(field, value) {
64 setForm((prev) => ({ ...prev, [field]: value }))
65 if (['pickupAddress', 'dropoffAddress', 'pickupAddresses', 'passengers', 'vipAirportPickup', 'oversizedLuggage', 'bookReturn', 'serviceType'].includes(field)) {
66 setCalculatedPrice(null)
67 }
68 }
69
70 function addPickup() {
71 if (form.pickupAddresses.length < 3) {
72 update('pickupAddresses', [...form.pickupAddresses, ''])
73 }
74 }
75
76 function updatePickup(i, value) {
77 const updated = [...form.pickupAddresses]
78 updated[i] = value
79 update('pickupAddresses', updated)
80 }
81
82 function removePickup(i) {
83 update('pickupAddresses', form.pickupAddresses.filter((_, idx) => idx !== i))
84 }
85
86 async function getPrice() {
87 if (!form.pickupAddress || !form.dropoffAddress) {
88 setError('Please enter both pickup and drop-off addresses')
89 return
90 }
91 setError('')
92 setPricingLoading(true)
93 try {
94 const { data } = await api.post('/admin/live-pricing', {
95 serviceType: form.serviceType,
96 pickupAddress: form.pickupAddress,
97 pickupAddresses: form.pickupAddresses.filter(Boolean),
98 dropoffAddress: form.dropoffAddress,
99 passengers: parseInt(form.passengers),
100 vipAirportPickup: form.vipAirportPickup,
101 oversizedLuggage: form.oversizedLuggage,
102 bookReturn: form.bookReturn,
103 })
104 setCalculatedPrice(data.pricing)
105 } catch (err) {
106 setError(err.response?.data?.detail || 'Failed to calculate price')
107 } finally {
108 setPricingLoading(false)
109 }
110 }
111
112 async function createBooking() {
113 if (!form.pickupAddress || !form.dropoffAddress || !form.date || !form.time || !form.name || !form.email || !form.phone) {
114 setError('Please fill in all required fields (addresses, date, time, name, email, phone)')
115 return
116 }
117 setError('')
118 setLoading(true)
119 try {
120 const payload = {
121 ...form,
122 pickupAddresses: form.pickupAddresses.filter(Boolean),
123 priceOverride: form.priceOverride ? parseFloat(form.priceOverride) : null,
124 }
125 const { data } = await api.post('/admin/bookings/create', payload)
126 setSuccess(data)
127 } catch (err) {
128 setError(err.response?.data?.detail || 'Failed to create booking')
129 } finally {
130 setLoading(false)
131 }
132 }
133
134 if (success) {
135 const booking = success.booking || {}
136 return (
137 <div className="max-w-lg mx-auto">
138 <div className="bg-white rounded-xl border border-gray-200 p-8 shadow-sm text-center">
139 <div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
140 <CheckCircle className="w-8 h-8 text-green-600" />
141 </div>
142 <h2 className="text-xl font-bold text-gray-900 mb-2">Booking Created</h2>
143 <p className="text-gray-600 mb-4">{success.message}</p>
144 <div className="bg-gray-50 rounded-lg p-4 text-sm text-left space-y-2 mb-6">
145 <div className="flex justify-between">
146 <span className="text-gray-500">Reference</span>
147 <span className="font-bold">#{booking.referenceNumber}</span>
148 </div>
149 <div className="flex justify-between">
150 <span className="text-gray-500">Customer</span>
151 <span>{booking.name}</span>
152 </div>
153 <div className="flex justify-between">
154 <span className="text-gray-500">Total</span>
155 <span className="font-bold text-[#d4a843]">${booking.totalPrice?.toFixed(2)} NZD</span>
156 </div>
157 {booking.pricing?.priceOverride && (
158 <div className="text-xs text-amber-600 font-medium">Price was manually overridden</div>
159 )}
160 {form.sendConfirmation && (
161 <div className="text-xs text-green-600 font-medium">Confirmation email sent to {booking.email}</div>
162 )}
163 </div>
164 <div className="flex gap-3">
165 <button
166 onClick={() => navigate('/admin/bookings')}
167 className="flex-1 px-4 py-2.5 bg-gray-100 text-gray-700 rounded-lg text-sm font-medium hover:bg-gray-200"
168 >
169 View Bookings
170 </button>
171 <button
172 onClick={() => { setSuccess(null); setForm({ ...form, name: '', email: '', phone: '', notes: '', priceOverride: '', date: '', time: '' }); setCalculatedPrice(null) }}
173 className="flex-1 px-4 py-2.5 bg-[#d4a843] text-white rounded-lg text-sm font-medium hover:bg-[#c49a3a]"
174 >
175 Create Another
176 </button>
177 </div>
178 </div>
179 </div>
180 )
181 }
182
183 const finalPrice = form.priceOverride ? parseFloat(form.priceOverride) : calculatedPrice?.totalPrice
184
185 return (
186 <div className="max-w-4xl mx-auto space-y-6">
187 <div className="grid lg:grid-cols-5 gap-6">
188 {/* Form — left side (3 cols) */}
189 <div className="lg:col-span-3 space-y-6">
190 {/* Trip details */}
191 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
192 <h2 className="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
193 <MapPin className="w-5 h-5 text-[#d4a843]" />
194 Trip Details
195 </h2>
196
197 <div className="space-y-4">
198 {/* Service type */}
199 <div>
200 <label className="block text-sm font-medium text-gray-700 mb-2">Service Type</label>
201 <div className="grid grid-cols-2 gap-3">
202 {SERVICE_TYPES.map((st) => (
203 <button
204 key={st.id}
205 onClick={() => update('serviceType', st.id)}
206 className={`flex items-center gap-2 p-3 rounded-lg border text-sm font-medium transition-all ${
207 form.serviceType === st.id
208 ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]'
209 : 'border-gray-200 text-gray-600 hover:border-gray-300'
210 }`}
211 >
212 <st.icon className="w-4 h-4" />
213 {st.label}
214 </button>
215 ))}
216 </div>
217 </div>
218
219 <AddressInput
220 label="Pickup Address *"
221 value={form.pickupAddress}
222 onChange={(v) => update('pickupAddress', v)}
223 placeholder="e.g. 123 Queen Street, Auckland"
224 />
225
226 {form.pickupAddresses.map((addr, i) => (
227 <div key={i} className="flex gap-2">
228 <div className="flex-1">
229 <AddressInput
230 label={`Additional Pickup ${i + 1}`}
231 value={addr}
232 onChange={(v) => updatePickup(i, v)}
233 placeholder="Additional pickup address"
234 />
235 </div>
236 <button onClick={() => removePickup(i)} className="self-end p-3 text-gray-400 hover:text-red-500">
237 <X className="w-5 h-5" />
238 </button>
239 </div>
240 ))}
241 {form.pickupAddresses.length < 3 && (
242 <button onClick={addPickup} className="text-sm text-[#d4a843] font-medium flex items-center gap-1 hover:underline">
243 <Plus className="w-4 h-4" /> Add another pickup
244 </button>
245 )}
246
247 <AddressInput
248 label="Drop-off Address *"
249 value={form.dropoffAddress}
250 onChange={(v) => update('dropoffAddress', v)}
251 placeholder="e.g. Auckland Airport"
252 icon={Plane}
253 />
254
255 {/* Date & Time */}
256 <div className="grid grid-cols-2 gap-3">
257 <div>
258 <label className="block text-sm font-medium text-gray-700 mb-1.5">Date *</label>
259 <div className="relative">
260 <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
261 <input
262 type="date"
263 value={form.date}
264 onChange={(e) => update('date', e.target.value)}
265 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
266 />
267 </div>
268 </div>
269 <div>
270 <label className="block text-sm font-medium text-gray-700 mb-1.5">Time *</label>
271 <div className="relative">
272 <Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
273 <input
274 type="time"
275 value={form.time}
276 onChange={(e) => update('time', e.target.value)}
277 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
278 />
279 </div>
280 </div>
281 </div>
282
283 {/* Passengers */}
284 <div>
285 <label className="block text-sm font-medium text-gray-700 mb-1.5">Passengers</label>
286 <div className="relative">
287 <Users className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
288 <select
289 value={form.passengers}
290 onChange={(e) => update('passengers', e.target.value)}
291 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843] bg-white appearance-none"
292 >
293 {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((n) => (
294 <option key={n} value={n}>{n} passenger{n > 1 ? 's' : ''}</option>
295 ))}
296 </select>
297 </div>
298 </div>
299
300 {/* Flight numbers */}
301 <div className="grid grid-cols-2 gap-3">
302 <div>
303 <label className="block text-sm font-medium text-gray-700 mb-1.5">Departure Flight</label>
304 <input
305 type="text"
306 value={form.departureFlightNumber}
307 onChange={(e) => update('departureFlightNumber', e.target.value)}
308 placeholder="e.g. NZ123"
309 className="w-full px-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
310 />
311 </div>
312 <div>
313 <label className="block text-sm font-medium text-gray-700 mb-1.5">Arrival Flight</label>
314 <input
315 type="text"
316 value={form.arrivalFlightNumber}
317 onChange={(e) => update('arrivalFlightNumber', e.target.value)}
318 placeholder="e.g. NZ456"
319 className="w-full px-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
320 />
321 </div>
322 </div>
323
324 {/* Options */}
325 <div className="space-y-3">
326 <label className="block text-sm font-medium text-gray-700">Options</label>
327 <div className="grid grid-cols-3 gap-2">
328 <button
329 onClick={() => update('vipAirportPickup', !form.vipAirportPickup)}
330 className={`flex items-center gap-1.5 p-2.5 rounded-lg border text-xs font-medium transition-all ${
331 form.vipAirportPickup ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]' : 'border-gray-200 text-gray-600 hover:border-gray-300'
332 }`}
333 >
334 <Star className="w-3.5 h-3.5" />
335 VIP (+$15)
336 </button>
337 <button
338 onClick={() => update('oversizedLuggage', !form.oversizedLuggage)}
339 className={`flex items-center gap-1.5 p-2.5 rounded-lg border text-xs font-medium transition-all ${
340 form.oversizedLuggage ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]' : 'border-gray-200 text-gray-600 hover:border-gray-300'
341 }`}
342 >
343 <Luggage className="w-3.5 h-3.5" />
344 Luggage (+$25)
345 </button>
346 <button
347 onClick={() => update('bookReturn', !form.bookReturn)}
348 className={`flex items-center gap-1.5 p-2.5 rounded-lg border text-xs font-medium transition-all ${
349 form.bookReturn ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]' : 'border-gray-200 text-gray-600 hover:border-gray-300'
350 }`}
351 >
352 <RotateCcw className="w-3.5 h-3.5" />
353 Return
354 </button>
355 </div>
356 </div>
357
358 {/* Return details */}
359 {form.bookReturn && (
360 <div className="grid grid-cols-3 gap-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
361 <div>
362 <label className="block text-xs font-medium text-gray-700 mb-1">Return Date</label>
363 <input
364 type="date"
365 value={form.returnDate}
366 onChange={(e) => update('returnDate', e.target.value)}
367 className="w-full px-2 py-2 border border-gray-300 rounded-lg text-sm"
368 />
369 </div>
370 <div>
371 <label className="block text-xs font-medium text-gray-700 mb-1">Return Time</label>
372 <input
373 type="time"
374 value={form.returnTime}
375 onChange={(e) => update('returnTime', e.target.value)}
376 className="w-full px-2 py-2 border border-gray-300 rounded-lg text-sm"
377 />
378 </div>
379 <div>
380 <label className="block text-xs font-medium text-gray-700 mb-1">Return Flight</label>
381 <input
382 type="text"
383 value={form.returnFlightNumber}
384 onChange={(e) => update('returnFlightNumber', e.target.value)}
385 placeholder="e.g. NZ789"
386 className="w-full px-2 py-2 border border-gray-300 rounded-lg text-sm"
387 />
388 </div>
389 </div>
390 )}
391 </div>
392 </div>
393
394 {/* Customer details */}
395 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
396 <h2 className="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
397 <User className="w-5 h-5 text-[#d4a843]" />
398 Customer Details
399 </h2>
400
401 <div className="space-y-4">
402 <div>
403 <label className="block text-sm font-medium text-gray-700 mb-1.5">Full Name *</label>
404 <div className="relative">
405 <User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
406 <input
407 type="text"
408 value={form.name}
409 onChange={(e) => update('name', e.target.value)}
410 placeholder="Customer full name"
411 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
412 />
413 </div>
414 </div>
415 <div className="grid grid-cols-2 gap-3">
416 <div>
417 <label className="block text-sm font-medium text-gray-700 mb-1.5">Email *</label>
418 <div className="relative">
419 <Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
420 <input
421 type="email"
422 value={form.email}
423 onChange={(e) => update('email', e.target.value)}
424 placeholder="customer@email.com"
425 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
426 />
427 </div>
428 </div>
429 <div>
430 <label className="block text-sm font-medium text-gray-700 mb-1.5">Phone *</label>
431 <div className="relative">
432 <Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
433 <input
434 type="tel"
435 value={form.phone}
436 onChange={(e) => update('phone', e.target.value)}
437 placeholder="021 123 4567"
438 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
439 />
440 </div>
441 </div>
442 </div>
443 <div>
444 <label className="block text-sm font-medium text-gray-700 mb-1.5">Notes</label>
445 <textarea
446 value={form.notes}
447 onChange={(e) => update('notes', e.target.value)}
448 rows={3}
449 placeholder="Any special requirements or notes..."
450 className="w-full px-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843] resize-none"
451 />
452 </div>
453 </div>
454 </div>
455 </div>
456
457 {/* Right side — pricing & submit (2 cols) */}
458 <div className="lg:col-span-2 space-y-6">
459 {/* Calculate price */}
460 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
461 <h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
462 <DollarSign className="w-5 h-5 text-[#d4a843]" />
463 Pricing
464 </h2>
465
466 <button
467 onClick={getPrice}
468 disabled={!form.pickupAddress || !form.dropoffAddress || pricingLoading}
469 className="w-full bg-gray-100 text-gray-700 font-medium py-2.5 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50 text-sm flex items-center justify-center gap-2 mb-4"
470 >
471 {pricingLoading ? (
472 <><Loader2 className="w-4 h-4 animate-spin" /> Calculating...</>
473 ) : (
474 <><DollarSign className="w-4 h-4" /> Calculate Price</>
475 )}
476 </button>
477
478 {calculatedPrice && (
479 <div className="space-y-2 text-sm mb-4">
480 <div className="flex justify-between">
481 <span className="text-gray-500">Distance</span>
482 <span>{calculatedPrice.distance?.toFixed(1)} km</span>
483 </div>
484 <div className="flex justify-between">
485 <span className="text-gray-500">Rate</span>
486 <span>${calculatedPrice.ratePerKm?.toFixed(2)}/km</span>
487 </div>
488 <div className="flex justify-between">
489 <span className="text-gray-500">Base</span>
490 <span>${calculatedPrice.basePrice?.toFixed(2)}</span>
491 </div>
492 {calculatedPrice.airportFee > 0 && (
493 <div className="flex justify-between">
494 <span className="text-gray-500">VIP</span>
495 <span>${calculatedPrice.airportFee?.toFixed(2)}</span>
496 </div>
497 )}
498 {calculatedPrice.stripeFee > 0 && (
499 <div className="flex justify-between">
500 <span className="text-gray-500">Card Fee</span>
501 <span>${calculatedPrice.stripeFee?.toFixed(2)}</span>
502 </div>
503 )}
504 <div className="flex justify-between font-bold border-t border-gray-200 pt-2">
505 <span>Calculated Total</span>
506 <span className="text-[#d4a843]">${calculatedPrice.totalPrice?.toFixed(2)}</span>
507 </div>
508 </div>
509 )}
510
511 {/* Price override */}
512 <div className="border-t border-gray-200 pt-4">
513 <label className="block text-sm font-medium text-gray-700 mb-1.5">
514 Price Override <span className="text-gray-400 font-normal">(optional)</span>
515 </label>
516 <div className="relative">
517 <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 font-medium">$</span>
518 <input
519 type="number"
520 step="0.01"
521 min="0"
522 value={form.priceOverride}
523 onChange={(e) => update('priceOverride', e.target.value)}
524 placeholder={calculatedPrice ? calculatedPrice.totalPrice?.toFixed(2) : 'Leave blank for auto pricing'}
525 className="w-full pl-8 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
526 />
527 </div>
528 {form.priceOverride && (
529 <div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600">
530 <AlertTriangle className="w-3.5 h-3.5" />
531 Calculated price will be overridden with ${parseFloat(form.priceOverride).toFixed(2)}
532 </div>
533 )}
534 </div>
535
536 {/* Final price display */}
537 {finalPrice && (
538 <div className="bg-gradient-to-br from-[#1a1a2e] to-[#16213e] rounded-xl p-5 text-center mt-4">
539 <div className="text-white/60 text-xs mb-1">{form.priceOverride ? 'Override Price' : 'Total Price'}</div>
540 <div className="text-3xl font-bold text-[#d4a843]">${finalPrice.toFixed(2)}</div>
541 <div className="text-white/50 text-xs mt-1">NZD</div>
542 </div>
543 )}
544 </div>
545
546 {/* Send confirmation toggle */}
547 <div className="bg-white rounded-xl border border-gray-200 p-4 shadow-sm">
548 <label className="flex items-center gap-3 cursor-pointer">
549 <input
550 type="checkbox"
551 checked={form.sendConfirmation}
552 onChange={(e) => update('sendConfirmation', e.target.checked)}
553 className="w-4 h-4 rounded border-gray-300 text-[#d4a843] focus:ring-[#d4a843]"
554 />
555 <div>
556 <div className="text-sm font-medium text-gray-700">Send confirmation email</div>
557 <div className="text-xs text-gray-400">Customer will receive booking confirmation from noreply@bookaride.co.nz</div>
558 </div>
559 </label>
560 </div>
561
562 {error && (
563 <div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">{error}</div>
564 )}
565
566 {/* Submit */}
567 <button
568 onClick={createBooking}
569 disabled={loading || !form.pickupAddress || !form.dropoffAddress || !form.date || !form.time || !form.name || !form.email || !form.phone}
570 className="w-full bg-green-600 text-white font-semibold py-3.5 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-sm"
571 >
572 {loading ? (
573 <><Loader2 className="w-5 h-5 animate-spin" /> Creating Booking...</>
574 ) : (
575 <><Send className="w-5 h-5" /> Create Booking & Send Confirmation</>
576 )}
577 </button>
578 </div>
579 </div>
580 </div>
581 )
582}
Addedfrontend/src/components/admin/AdminDashboard.jsx+96−0View fileUnifiedSplit
@@ -0,0 +1,96 @@
1import { useState, useEffect } from 'react'
2import { Link } from 'react-router-dom'
3import { BookOpen, Clock, CheckCircle, XCircle, Activity, Loader2 } from 'lucide-react'
4import api from '../../lib/api'
5
6export default function AdminDashboard() {
7 const [stats, setStats] = useState(null)
8 const [health, setHealth] = useState(null)
9 const [loading, setLoading] = useState(true)
10
11 useEffect(() => {
12 Promise.all([
13 api.get('/admin/dashboard').then((r) => setStats(r.data)),
14 api.get('/admin/system-health').then((r) => setHealth(r.data)),
15 ])
16 .catch(() => {})
17 .finally(() => setLoading(false))
18 }, [])
19
20 if (loading) {
21 return (
22 <div className="flex items-center justify-center h-64">
23 <Loader2 className="w-8 h-8 animate-spin text-gray-400" />
24 </div>
25 )
26 }
27
28 const cards = [
29 { label: 'Total Bookings', value: stats?.total_bookings || 0, icon: BookOpen, color: 'bg-blue-500' },
30 { label: "Today's Bookings", value: stats?.todays_bookings || 0, icon: Clock, color: 'bg-purple-500' },
31 { label: 'Pending', value: stats?.pending || 0, icon: Clock, color: 'bg-yellow-500' },
32 { label: 'Confirmed', value: stats?.confirmed || 0, icon: CheckCircle, color: 'bg-green-500' },
33 ]
34
35 return (
36 <div className="space-y-6">
37 {/* Stat cards */}
38 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
39 {cards.map((card) => (
40 <div key={card.label} className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
41 <div className="flex items-center justify-between mb-3">
42 <span className="text-sm text-gray-500">{card.label}</span>
43 <div className={`w-10 h-10 ${card.color} rounded-lg flex items-center justify-center`}>
44 <card.icon className="w-5 h-5 text-white" />
45 </div>
46 </div>
47 <div className="text-2xl font-bold text-gray-900">{card.value}</div>
48 </div>
49 ))}
50 </div>
51
52 {/* System health */}
53 <div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
54 <h2 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
55 <Activity className="w-5 h-5 text-gray-400" />
56 System Health
57 </h2>
58 <div className="grid sm:grid-cols-2 gap-4">
59 <div className="flex items-center gap-3">
60 <div className={`w-3 h-3 rounded-full ${health?.database === 'healthy' ? 'bg-green-500' : 'bg-red-500'}`} />
61 <span className="text-sm text-gray-600">Database: <strong>{health?.database || 'unknown'}</strong></span>
62 </div>
63 <div className="flex items-center gap-3">
64 <div className={`w-3 h-3 rounded-full ${health?.api === 'healthy' ? 'bg-green-500' : 'bg-red-500'}`} />
65 <span className="text-sm text-gray-600">API: <strong>{health?.api || 'unknown'}</strong></span>
66 </div>
67 </div>
68 </div>
69
70 {/* Quick actions */}
71 <div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
72 <h2 className="text-lg font-semibold text-gray-800 mb-4">Quick Actions</h2>
73 <div className="flex flex-wrap gap-3">
74 <Link
75 to="/admin/bookings"
76 className="px-4 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors"
77 >
78 View All Bookings
79 </Link>
80 <Link
81 to="/admin/live-pricing"
82 className="px-4 py-2 bg-amber-50 text-amber-700 rounded-lg text-sm font-medium hover:bg-amber-100 transition-colors"
83 >
84 Get a Quick Price
85 </Link>
86 <Link
87 to="/admin/email"
88 className="px-4 py-2 bg-green-50 text-green-700 rounded-lg text-sm font-medium hover:bg-green-100 transition-colors"
89 >
90 Test Email Setup
91 </Link>
92 </div>
93 </div>
94 </div>
95 )
96}
Addedfrontend/src/components/admin/AdminEmail.jsx+200−0View fileUnifiedSplit
@@ -0,0 +1,200 @@
1import { useState, useEffect } from 'react'
2import {
3 Mail,
4 Send,
5 Loader2,
6 CheckCircle,
7 XCircle,
8 RefreshCw,
9 Clock,
10} from 'lucide-react'
11import api from '../../lib/api'
12
13export default function AdminEmail() {
14 const [testEmail, setTestEmail] = useState('')
15 const [testSubject, setTestSubject] = useState('BookARide — Test Email')
16 const [testMessage, setTestMessage] = useState('This is a test email from the BookARide admin panel. If you receive this, email delivery is working correctly.')
17 const [sending, setSending] = useState(false)
18 const [result, setResult] = useState(null)
19 const [logs, setLogs] = useState([])
20 const [logsLoading, setLogsLoading] = useState(false)
21
22 async function fetchLogs() {
23 setLogsLoading(true)
24 try {
25 const { data } = await api.get('/admin/email/logs')
26 setLogs(data.logs || [])
27 } catch {
28 setLogs([])
29 } finally {
30 setLogsLoading(false)
31 }
32 }
33
34 useEffect(() => {
35 fetchLogs()
36 }, [])
37
38 async function sendTest(e) {
39 e.preventDefault()
40 if (!testEmail) return
41 setSending(true)
42 setResult(null)
43 try {
44 const { data } = await api.post('/admin/email/test', {
45 to: testEmail,
46 subject: testSubject,
47 message: testMessage,
48 })
49 setResult({ success: true, message: data.message })
50 fetchLogs()
51 } catch (err) {
52 setResult({ success: false, message: err.response?.data?.detail || 'Failed to send email' })
53 } finally {
54 setSending(false)
55 }
56 }
57
58 return (
59 <div className="max-w-4xl mx-auto space-y-6">
60 {/* Send test email */}
61 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
62 <h2 className="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
63 <Mail className="w-5 h-5 text-[#d4a843]" />
64 Test Email Delivery
65 </h2>
66 <p className="text-sm text-gray-500 mb-5">
67 Send a test email to verify that the Mailgun email delivery system is configured and working correctly.
68 </p>
69
70 <form onSubmit={sendTest} className="space-y-4">
71 <div>
72 <label className="block text-sm font-medium text-gray-700 mb-1.5">Recipient Email</label>
73 <input
74 type="email"
75 value={testEmail}
76 onChange={(e) => setTestEmail(e.target.value)}
77 placeholder="test@example.com"
78 required
79 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
80 />
81 </div>
82
83 <div>
84 <label className="block text-sm font-medium text-gray-700 mb-1.5">Subject</label>
85 <input
86 type="text"
87 value={testSubject}
88 onChange={(e) => setTestSubject(e.target.value)}
89 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
90 />
91 </div>
92
93 <div>
94 <label className="block text-sm font-medium text-gray-700 mb-1.5">Message</label>
95 <textarea
96 value={testMessage}
97 onChange={(e) => setTestMessage(e.target.value)}
98 rows={3}
99 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843] resize-none"
100 />
101 </div>
102
103 {result && (
104 <div className={`p-3 rounded-lg text-sm font-medium flex items-center gap-2 ${
105 result.success
106 ? 'bg-green-50 text-green-700 border border-green-200'
107 : 'bg-red-50 text-red-700 border border-red-200'
108 }`}>
109 {result.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
110 {result.message}
111 </div>
112 )}
113
114 <button
115 type="submit"
116 disabled={sending || !testEmail}
117 className="bg-[#d4a843] text-white font-semibold px-6 py-3 rounded-lg hover:bg-[#c49a3a] transition-colors disabled:opacity-50 flex items-center gap-2"
118 >
119 {sending ? (
120 <><Loader2 className="w-5 h-5 animate-spin" /> Sending...</>
121 ) : (
122 <><Send className="w-5 h-5" /> Send Test Email</>
123 )}
124 </button>
125 </form>
126 </div>
127
128 {/* Email configuration status */}
129 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
130 <h2 className="text-lg font-semibold text-gray-800 mb-4">Email Configuration</h2>
131 <div className="grid sm:grid-cols-2 gap-4 text-sm">
132 <div className="flex items-center gap-3">
133 <div className="w-3 h-3 rounded-full bg-blue-500" />
134 <span className="text-gray-600">Provider: <strong>Mailgun</strong></span>
135 </div>
136 <div className="flex items-center gap-3">
137 <div className="w-3 h-3 rounded-full bg-blue-500" />
138 <span className="text-gray-600">Domain: <strong>bookaride.co.nz</strong></span>
139 </div>
140 <div className="flex items-center gap-3">
141 <div className="w-3 h-3 rounded-full bg-blue-500" />
142 <span className="text-gray-600">From: <strong>noreply@bookaride.co.nz</strong></span>
143 </div>
144 <div className="flex items-center gap-3">
145 <div className="w-3 h-3 rounded-full bg-green-500" />
146 <span className="text-gray-600">Booking confirmation: <strong>Automatic</strong></span>
147 </div>
148 </div>
149 </div>
150
151 {/* Email logs */}
152 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
153 <div className="flex items-center justify-between mb-4">
154 <h2 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
155 <Clock className="w-5 h-5 text-gray-400" />
156 Recent Email Logs
157 </h2>
158 <button
159 onClick={fetchLogs}
160 disabled={logsLoading}
161 className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1"
162 >
163 <RefreshCw className={`w-4 h-4 ${logsLoading ? 'animate-spin' : ''}`} />
164 Refresh
165 </button>
166 </div>
167
168 {logsLoading && logs.length === 0 ? (
169 <div className="flex items-center justify-center py-8">
170 <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
171 </div>
172 ) : logs.length === 0 ? (
173 <div className="text-center py-8 text-gray-400 text-sm">No email logs yet. Send a test email to get started.</div>
174 ) : (
175 <div className="space-y-2">
176 {logs.map((log, i) => (
177 <div
178 key={log.id || i}
179 className="flex items-center gap-3 p-3 rounded-lg bg-gray-50 text-sm"
180 >
181 {log.status === 'sent' ? (
182 <CheckCircle className="w-4 h-4 text-green-500 shrink-0" />
183 ) : (
184 <XCircle className="w-4 h-4 text-red-500 shrink-0" />
185 )}
186 <div className="flex-1 min-w-0">
187 <div className="text-gray-700 font-medium truncate">{log.subject}</div>
188 <div className="text-gray-400 text-xs truncate">To: {log.to}</div>
189 </div>
190 <div className="text-xs text-gray-400 shrink-0">
191 {log.sentAt ? new Date(log.sentAt).toLocaleString() : '—'}
192 </div>
193 </div>
194 ))}
195 </div>
196 )}
197 </div>
198 </div>
199 )
200}
Addedfrontend/src/components/admin/AdminLayout.jsx+143−0View fileUnifiedSplit
@@ -0,0 +1,143 @@
1import { useState, useEffect } from 'react'
2import { Routes, Route, Navigate, Link, useLocation, useNavigate } from 'react-router-dom'
3import {
4 LayoutDashboard,
5 BookOpen,
6 DollarSign,
7 Mail,
8 LogOut,
9 Menu,
10 X,
11 Shield,
12 PlusCircle,
13} from 'lucide-react'
14import api from '../../lib/api'
15import AdminLogin from './AdminLogin'
16import AdminRegister from './AdminRegister'
17import AdminDashboard from './AdminDashboard'
18import AdminBookings from './AdminBookings'
19import AdminLivePricing from './AdminLivePricing'
20import AdminEmail from './AdminEmail'
21import AdminCreateBooking from './AdminCreateBooking'
22
23const NAV_ITEMS = [
24 { path: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard },
25 { path: '/admin/bookings', label: 'Bookings', icon: BookOpen },
26 { path: '/admin/create-booking', label: 'New Booking', icon: PlusCircle },
27 { path: '/admin/live-pricing', label: 'Live Pricing', icon: DollarSign },
28 { path: '/admin/email', label: 'Email', icon: Mail },
29]
30
31function AdminShell() {
32 const [sidebarOpen, setSidebarOpen] = useState(false)
33 const [admin, setAdmin] = useState(null)
34 const location = useLocation()
35 const navigate = useNavigate()
36
37 useEffect(() => {
38 api.get('/admin/me').then((r) => setAdmin(r.data)).catch(() => {
39 localStorage.removeItem('admin_token')
40 navigate('/admin/login')
41 })
42 }, [navigate])
43
44 function logout() {
45 localStorage.removeItem('admin_token')
46 navigate('/admin/login')
47 }
48
49 return (
50 <div className="flex h-screen bg-gray-100">
51 {/* Mobile overlay */}
52 {sidebarOpen && (
53 <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
54 )}
55
56 {/* Sidebar */}
57 <aside className={`
58 fixed inset-y-0 left-0 z-50 w-64 bg-[#1a1a2e] text-white transform transition-transform lg:translate-x-0 lg:static lg:z-auto
59 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
60 `}>
61 <div className="flex items-center justify-between p-4 border-b border-white/10">
62 <div className="flex items-center gap-2">
63 <Shield className="w-6 h-6 text-[#d4a843]" />
64 <span className="font-bold text-lg">BookARide Admin</span>
65 </div>
66 <button onClick={() => setSidebarOpen(false)} className="lg:hidden text-white/70 hover:text-white">
67 <X className="w-5 h-5" />
68 </button>
69 </div>
70
71 <nav className="p-4 space-y-1">
72 {NAV_ITEMS.map((item) => (
73 <Link
74 key={item.path}
75 to={item.path}
76 onClick={() => setSidebarOpen(false)}
77 className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
78 location.pathname === item.path
79 ? 'bg-[#d4a843]/20 text-[#d4a843]'
80 : 'text-white/70 hover:bg-white/10 hover:text-white'
81 }`}
82 >
83 <item.icon className="w-5 h-5" />
84 {item.label}
85 </Link>
86 ))}
87 </nav>
88
89 <div className="absolute bottom-0 left-0 right-0 p-4 border-t border-white/10">
90 {admin && (
91 <div className="text-sm text-white/60 mb-2 truncate">
92 Logged in as <span className="text-white font-medium">{admin.username}</span>
93 </div>
94 )}
95 <button
96 onClick={logout}
97 className="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm text-white/70 hover:bg-white/10 hover:text-white transition-colors"
98 >
99 <LogOut className="w-4 h-4" />
100 Sign Out
101 </button>
102 </div>
103 </aside>
104
105 {/* Main content */}
106 <div className="flex-1 flex flex-col overflow-hidden">
107 {/* Top bar */}
108 <header className="bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-4 lg:px-6">
109 <button onClick={() => setSidebarOpen(true)} className="lg:hidden text-gray-600">
110 <Menu className="w-6 h-6" />
111 </button>
112 <h1 className="text-lg font-semibold text-gray-800">
113 {NAV_ITEMS.find((i) => i.path === location.pathname)?.label || 'Admin'}
114 </h1>
115 </header>
116
117 {/* Page content */}
118 <main className="flex-1 overflow-auto p-4 lg:p-6">
119 <Routes>
120 <Route index element={<Navigate to="dashboard" replace />} />
121 <Route path="dashboard" element={<AdminDashboard />} />
122 <Route path="bookings" element={<AdminBookings />} />
123 <Route path="create-booking" element={<AdminCreateBooking />} />
124 <Route path="live-pricing" element={<AdminLivePricing />} />
125 <Route path="email" element={<AdminEmail />} />
126 </Routes>
127 </main>
128 </div>
129 </div>
130 )
131}
132
133export default function AdminLayout() {
134 const token = localStorage.getItem('admin_token')
135
136 return (
137 <Routes>
138 <Route path="login" element={<AdminLogin />} />
139 <Route path="register" element={<AdminRegister />} />
140 <Route path="*" element={token ? <AdminShell /> : <Navigate to="/admin/login" replace />} />
141 </Routes>
142 )
143}
Addedfrontend/src/components/admin/AdminLivePricing.jsx+330−0View fileUnifiedSplit
@@ -0,0 +1,330 @@
1import { useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import {
4 Plane,
5 MapPin,
6 Users,
7 Loader2,
8 DollarSign,
9 Star,
10 Luggage,
11 RotateCcw,
12 ArrowRight,
13 Plus,
14 X,
15 Info,
16} from 'lucide-react'
17import api from '../../lib/api'
18import AddressInput from '../booking/AddressInput'
19
20const SERVICE_TYPES = [
21 { id: 'airport-transfer', label: 'Airport Transfer', icon: Plane },
22 { id: 'point-to-point', label: 'Point to Point', icon: MapPin },
23]
24
25export default function AdminLivePricing() {
26 const navigate = useNavigate()
27 const [loading, setLoading] = useState(false)
28 const [error, setError] = useState('')
29 const [pricing, setPricing] = useState(null)
30 const [enquiries, setEnquiries] = useState([])
31
32 const [form, setForm] = useState({
33 serviceType: 'airport-transfer',
34 pickupAddress: '',
35 pickupAddresses: [],
36 dropoffAddress: '',
37 passengers: 1,
38 vipAirportPickup: false,
39 oversizedLuggage: false,
40 bookReturn: false,
41 })
42
43 function update(field, value) {
44 setForm((prev) => ({ ...prev, [field]: value }))
45 setPricing(null)
46 }
47
48 function addPickup() {
49 if (form.pickupAddresses.length < 3) {
50 update('pickupAddresses', [...form.pickupAddresses, ''])
51 }
52 }
53
54 function updatePickup(i, value) {
55 const updated = [...form.pickupAddresses]
56 updated[i] = value
57 update('pickupAddresses', updated)
58 }
59
60 function removePickup(i) {
61 update('pickupAddresses', form.pickupAddresses.filter((_, idx) => idx !== i))
62 }
63
64 async function getPrice() {
65 if (!form.pickupAddress || !form.dropoffAddress) {
66 setError('Please enter both pickup and drop-off addresses')
67 return
68 }
69 setError('')
70 setLoading(true)
71 try {
72 const { data } = await api.post('/admin/live-pricing', {
73 serviceType: form.serviceType,
74 pickupAddress: form.pickupAddress,
75 pickupAddresses: form.pickupAddresses.filter(Boolean),
76 dropoffAddress: form.dropoffAddress,
77 passengers: form.passengers,
78 vipAirportPickup: form.vipAirportPickup,
79 oversizedLuggage: form.oversizedLuggage,
80 bookReturn: form.bookReturn,
81 })
82 setPricing(data.pricing)
83 } catch (err) {
84 setError(err.response?.data?.detail || 'Failed to calculate price')
85 } finally {
86 setLoading(false)
87 }
88 }
89
90 function proceedToBooking() {
91 // Pre-fill the public booking form
92 const params = new URLSearchParams({
93 serviceType: form.serviceType,
94 pickup: form.pickupAddress,
95 dropoff: form.dropoffAddress,
96 passengers: form.passengers.toString(),
97 })
98 window.open(`/book-now?${params.toString()}`, '_blank')
99 }
100
101 return (
102 <div className="max-w-4xl mx-auto space-y-6">
103 {/* Info banner */}
104 <div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
105 <Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
106 <div className="text-sm text-amber-800">
107 <strong>Live Pricing Tool</strong> — Use this to give customers a quick price estimate
108 without creating a booking. If the customer wants to proceed, use the “Proceed to Booking”
109 button to open the booking form with pre-filled details.
110 </div>
111 </div>
112
113 <div className="grid lg:grid-cols-2 gap-6">
114 {/* Form */}
115 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
116 <h2 className="text-lg font-semibold text-gray-800 mb-5 flex items-center gap-2">
117 <DollarSign className="w-5 h-5 text-[#d4a843]" />
118 Price Calculator
119 </h2>
120
121 <div className="space-y-4">
122 {/* Service type */}
123 <div>
124 <label className="block text-sm font-medium text-gray-700 mb-2">Service Type</label>
125 <div className="grid grid-cols-2 gap-3">
126 {SERVICE_TYPES.map((st) => (
127 <button
128 key={st.id}
129 onClick={() => update('serviceType', st.id)}
130 className={`flex items-center gap-2 p-3 rounded-lg border text-sm font-medium transition-all ${
131 form.serviceType === st.id
132 ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]'
133 : 'border-gray-200 text-gray-600 hover:border-gray-300'
134 }`}
135 >
136 <st.icon className="w-4 h-4" />
137 {st.label}
138 </button>
139 ))}
140 </div>
141 </div>
142
143 {/* Pickup */}
144 <AddressInput
145 label="Pickup Address"
146 value={form.pickupAddress}
147 onChange={(v) => update('pickupAddress', v)}
148 placeholder="e.g. 123 Queen Street, Auckland"
149 />
150
151 {/* Additional pickups */}
152 {form.pickupAddresses.map((addr, i) => (
153 <div key={i} className="flex gap-2">
154 <div className="flex-1">
155 <AddressInput
156 label={`Additional Pickup ${i + 1}`}
157 value={addr}
158 onChange={(v) => updatePickup(i, v)}
159 placeholder="Additional pickup address"
160 />
161 </div>
162 <button onClick={() => removePickup(i)} className="self-end p-3 text-gray-400 hover:text-red-500">
163 <X className="w-5 h-5" />
164 </button>
165 </div>
166 ))}
167 {form.pickupAddresses.length < 3 && (
168 <button onClick={addPickup} className="text-sm text-[#d4a843] font-medium flex items-center gap-1 hover:underline">
169 <Plus className="w-4 h-4" /> Add another pickup
170 </button>
171 )}
172
173 {/* Dropoff */}
174 <AddressInput
175 label="Drop-off Address"
176 value={form.dropoffAddress}
177 onChange={(v) => update('dropoffAddress', v)}
178 placeholder="e.g. Auckland Airport"
179 icon={Plane}
180 />
181
182 {/* Passengers */}
183 <div>
184 <label className="block text-sm font-medium text-gray-700 mb-1.5">Passengers</label>
185 <div className="relative">
186 <Users className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
187 <select
188 value={form.passengers}
189 onChange={(e) => update('passengers', parseInt(e.target.value))}
190 className="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843] bg-white appearance-none"
191 >
192 {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((n) => (
193 <option key={n} value={n}>{n} passenger{n > 1 ? 's' : ''}</option>
194 ))}
195 </select>
196 </div>
197 </div>
198
199 {/* Options */}
200 <div className="space-y-3">
201 <label className="block text-sm font-medium text-gray-700">Options</label>
202 <div className="grid grid-cols-3 gap-2">
203 <button
204 onClick={() => update('vipAirportPickup', !form.vipAirportPickup)}
205 className={`flex items-center gap-1.5 p-2.5 rounded-lg border text-xs font-medium transition-all ${
206 form.vipAirportPickup ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]' : 'border-gray-200 text-gray-600 hover:border-gray-300'
207 }`}
208 >
209 <Star className="w-3.5 h-3.5" />
210 VIP (+$15)
211 </button>
212 <button
213 onClick={() => update('oversizedLuggage', !form.oversizedLuggage)}
214 className={`flex items-center gap-1.5 p-2.5 rounded-lg border text-xs font-medium transition-all ${
215 form.oversizedLuggage ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]' : 'border-gray-200 text-gray-600 hover:border-gray-300'
216 }`}
217 >
218 <Luggage className="w-3.5 h-3.5" />
219 Luggage (+$25)
220 </button>
221 <button
222 onClick={() => update('bookReturn', !form.bookReturn)}
223 className={`flex items-center gap-1.5 p-2.5 rounded-lg border text-xs font-medium transition-all ${
224 form.bookReturn ? 'border-[#d4a843] bg-amber-50 text-[#d4a843]' : 'border-gray-200 text-gray-600 hover:border-gray-300'
225 }`}
226 >
227 <RotateCcw className="w-3.5 h-3.5" />
228 Return
229 </button>
230 </div>
231 </div>
232
233 {error && <div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">{error}</div>}
234
235 <button
236 onClick={getPrice}
237 disabled={!form.pickupAddress || !form.dropoffAddress || loading}
238 className="w-full bg-[#d4a843] text-white font-semibold py-3 rounded-lg hover:bg-[#c49a3a] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
239 >
240 {loading ? (
241 <><Loader2 className="w-5 h-5 animate-spin" /> Calculating...</>
242 ) : (
243 <><DollarSign className="w-5 h-5" /> Get Price</>
244 )}
245 </button>
246 </div>
247 </div>
248
249 {/* Result */}
250 <div>
251 {pricing ? (
252 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
253 <h2 className="text-lg font-semibold text-gray-800 mb-4">Price Estimate</h2>
254
255 <div className="bg-gradient-to-br from-[#1a1a2e] to-[#16213e] rounded-xl p-6 text-center mb-5">
256 <div className="text-white/60 text-sm mb-1">Estimated Total</div>
257 <div className="text-4xl font-bold text-[#d4a843]">${pricing.totalPrice?.toFixed(2)}</div>
258 <div className="text-white/50 text-sm mt-1">NZD (inc. card fee)</div>
259 </div>
260
261 <div className="space-y-2 text-sm mb-5">
262 <div className="flex justify-between">
263 <span className="text-gray-500">Distance</span>
264 <span className="text-gray-700">{pricing.distance?.toFixed(1)} km</span>
265 </div>
266 <div className="flex justify-between">
267 <span className="text-gray-500">Rate</span>
268 <span className="text-gray-700">${pricing.ratePerKm?.toFixed(2)}/km</span>
269 </div>
270 <div className="flex justify-between">
271 <span className="text-gray-500">Base Fare</span>
272 <span className="text-gray-700">${pricing.basePrice?.toFixed(2)}</span>
273 </div>
274 {pricing.airportFee > 0 && (
275 <div className="flex justify-between">
276 <span className="text-gray-500">VIP Airport Pickup</span>
277 <span className="text-gray-700">${pricing.airportFee?.toFixed(2)}</span>
278 </div>
279 )}
280 {pricing.oversizedLuggageFee > 0 && (
281 <div className="flex justify-between">
282 <span className="text-gray-500">Oversized Luggage</span>
283 <span className="text-gray-700">${pricing.oversizedLuggageFee?.toFixed(2)}</span>
284 </div>
285 )}
286 {pricing.passengerFee > 0 && (
287 <div className="flex justify-between">
288 <span className="text-gray-500">Extra Passengers</span>
289 <span className="text-gray-700">${pricing.passengerFee?.toFixed(2)}</span>
290 </div>
291 )}
292 <div className="flex justify-between border-t border-gray-200 pt-2">
293 <span className="text-gray-500">Subtotal</span>
294 <span className="text-gray-700">${pricing.subtotal?.toFixed(2)}</span>
295 </div>
296 <div className="flex justify-between">
297 <span className="text-gray-500">Card Fee</span>
298 <span className="text-gray-700">${pricing.stripeFee?.toFixed(2)}</span>
299 </div>
300 <div className="flex justify-between font-bold border-t border-gray-200 pt-2">
301 <span className="text-gray-900">Total</span>
302 <span className="text-[#d4a843]">${pricing.totalPrice?.toFixed(2)} NZD</span>
303 </div>
304 </div>
305
306 <div className="bg-gray-50 rounded-lg p-3 text-xs text-gray-500 mb-4">
307 This is a price estimate only. No booking has been created. The customer can proceed
308 to the booking form to complete their reservation and payment.
309 </div>
310
311 <button
312 onClick={proceedToBooking}
313 className="w-full bg-green-600 text-white font-semibold py-3 rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2"
314 >
315 Proceed to Booking <ArrowRight className="w-5 h-5" />
316 </button>
317 </div>
318 ) : (
319 <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm flex items-center justify-center h-full">
320 <div className="text-center text-gray-400">
321 <DollarSign className="w-12 h-12 mx-auto mb-3 opacity-50" />
322 <p className="text-sm">Enter trip details and click “Get Price” to see the estimate</p>
323 </div>
324 </div>
325 )}
326 </div>
327 </div>
328 </div>
329 )
330}
Addedfrontend/src/components/admin/AdminLogin.jsx+144−0View fileUnifiedSplit
@@ -0,0 +1,144 @@
1import { useState, useEffect, useRef } from 'react'
2import { useNavigate, Link } from 'react-router-dom'
3import { Shield, Loader2 } from 'lucide-react'
4import api from '../../lib/api'
5
6const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || ''
7
8export default function AdminLogin() {
9 const navigate = useNavigate()
10 const [username, setUsername] = useState('')
11 const [password, setPassword] = useState('')
12 const [loading, setLoading] = useState(false)
13 const [error, setError] = useState('')
14 const googleBtnRef = useRef(null)
15
16 useEffect(() => {
17 if (!GOOGLE_CLIENT_ID || !window.google) return
18
19 window.google.accounts.id.initialize({
20 client_id: GOOGLE_CLIENT_ID,
21 callback: handleGoogleResponse,
22 })
23 window.google.accounts.id.renderButton(googleBtnRef.current, {
24 theme: 'outline',
25 size: 'large',
26 width: '100%',
27 text: 'signin_with',
28 })
29 }, [])
30
31 async function handleGoogleResponse(response) {
32 setError('')
33 setLoading(true)
34 try {
35 const { data } = await api.post('/admin/auth/google', {
36 credential: response.credential,
37 })
38 localStorage.setItem('admin_token', data.access_token)
39 navigate('/admin/dashboard')
40 } catch (err) {
41 setError(err.response?.data?.detail || 'Google sign-in failed')
42 } finally {
43 setLoading(false)
44 }
45 }
46
47 async function handleLogin(e) {
48 e.preventDefault()
49 setError('')
50 setLoading(true)
51 try {
52 const { data } = await api.post('/admin/login', { username, password })
53 localStorage.setItem('admin_token', data.access_token)
54 navigate('/admin/dashboard')
55 } catch (err) {
56 setError(err.response?.data?.detail || 'Login failed')
57 } finally {
58 setLoading(false)
59 }
60 }
61
62 return (
63 <div className="min-h-screen bg-[#1a1a2e] flex items-center justify-center p-4">
64 <div className="w-full max-w-md">
65 <div className="text-center mb-8">
66 <Shield className="w-12 h-12 text-[#d4a843] mx-auto mb-3" />
67 <h1 className="text-2xl font-bold text-white">BookARide Admin</h1>
68 <p className="text-white/50 text-sm mt-1">Sign in to manage bookings</p>
69 </div>
70
71 <div className="bg-white rounded-2xl p-8 shadow-xl space-y-5">
72 {error && (
73 <div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
74 {error}
75 </div>
76 )}
77
78 {/* Google Sign-In */}
79 {GOOGLE_CLIENT_ID && (
80 <>
81 <div ref={googleBtnRef} className="flex justify-center" />
82 <div className="relative">
83 <div className="absolute inset-0 flex items-center">
84 <div className="w-full border-t border-gray-200" />
85 </div>
86 <div className="relative flex justify-center text-xs">
87 <span className="bg-white px-3 text-gray-400">or sign in with credentials</span>
88 </div>
89 </div>
90 </>
91 )}
92
93 {/* Username/Password form */}
94 <form onSubmit={handleLogin} className="space-y-5">
95 <div>
96 <label className="block text-sm font-medium text-gray-700 mb-1.5">Username</label>
97 <input
98 type="text"
99 value={username}
100 onChange={(e) => setUsername(e.target.value)}
101 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
102 placeholder="admin"
103 required
104 />
105 </div>
106
107 <div>
108 <label className="block text-sm font-medium text-gray-700 mb-1.5">Password</label>
109 <input
110 type="password"
111 value={password}
112 onChange={(e) => setPassword(e.target.value)}
113 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
114 placeholder="Enter password"
115 required
116 />
117 </div>
118
119 <button
120 type="submit"
121 disabled={loading}
122 className="w-full bg-[#d4a843] text-white font-semibold py-3 rounded-lg hover:bg-[#c49a3a] transition-colors disabled:opacity-50"
123 >
124 {loading ? (
125 <span className="flex items-center justify-center gap-2">
126 <Loader2 className="w-5 h-5 animate-spin" /> Signing in...
127 </span>
128 ) : (
129 'Sign In'
130 )}
131 </button>
132 </form>
133
134 <p className="text-center text-sm text-gray-500">
135 No account yet?{' '}
136 <Link to="/admin/register" className="text-[#d4a843] font-medium hover:underline">
137 Create one
138 </Link>
139 </p>
140 </div>
141 </div>
142 </div>
143 )
144}
Addedfrontend/src/components/admin/AdminRegister.jsx+131−0View fileUnifiedSplit
@@ -0,0 +1,131 @@
1import { useState } from 'react'
2import { useNavigate, Link } from 'react-router-dom'
3import { Shield, Loader2 } from 'lucide-react'
4import api from '../../lib/api'
5
6export default function AdminRegister() {
7 const navigate = useNavigate()
8 const [username, setUsername] = useState('')
9 const [email, setEmail] = useState('')
10 const [password, setPassword] = useState('')
11 const [confirmPassword, setConfirmPassword] = useState('')
12 const [loading, setLoading] = useState(false)
13 const [error, setError] = useState('')
14
15 async function handleRegister(e) {
16 e.preventDefault()
17 setError('')
18
19 if (password !== confirmPassword) {
20 setError('Passwords do not match')
21 return
22 }
23
24 if (password.length < 6) {
25 setError('Password must be at least 6 characters')
26 return
27 }
28
29 setLoading(true)
30 try {
31 const { data } = await api.post('/admin/register', { username, email, password })
32 localStorage.setItem('admin_token', data.access_token)
33 navigate('/admin/dashboard')
34 } catch (err) {
35 setError(err.response?.data?.detail || 'Registration failed')
36 } finally {
37 setLoading(false)
38 }
39 }
40
41 return (
42 <div className="min-h-screen bg-[#1a1a2e] flex items-center justify-center p-4">
43 <div className="w-full max-w-md">
44 <div className="text-center mb-8">
45 <Shield className="w-12 h-12 text-[#d4a843] mx-auto mb-3" />
46 <h1 className="text-2xl font-bold text-white">Create Admin Account</h1>
47 <p className="text-white/50 text-sm mt-1">Set up your admin credentials</p>
48 </div>
49
50 <div className="bg-white rounded-2xl p-8 shadow-xl space-y-5">
51 {error && (
52 <div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
53 {error}
54 </div>
55 )}
56
57 <form onSubmit={handleRegister} className="space-y-5">
58 <div>
59 <label className="block text-sm font-medium text-gray-700 mb-1.5">Username</label>
60 <input
61 type="text"
62 value={username}
63 onChange={(e) => setUsername(e.target.value)}
64 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
65 placeholder="Choose a username"
66 required
67 />
68 </div>
69
70 <div>
71 <label className="block text-sm font-medium text-gray-700 mb-1.5">Email</label>
72 <input
73 type="email"
74 value={email}
75 onChange={(e) => setEmail(e.target.value)}
76 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
77 placeholder="admin@bookaride.co.nz"
78 required
79 />
80 </div>
81
82 <div>
83 <label className="block text-sm font-medium text-gray-700 mb-1.5">Password</label>
84 <input
85 type="password"
86 value={password}
87 onChange={(e) => setPassword(e.target.value)}
88 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
89 placeholder="Choose a password"
90 required
91 />
92 </div>
93
94 <div>
95 <label className="block text-sm font-medium text-gray-700 mb-1.5">Confirm Password</label>
96 <input
97 type="password"
98 value={confirmPassword}
99 onChange={(e) => setConfirmPassword(e.target.value)}
100 className="w-full px-4 py-3 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-[#d4a843]/40 focus:border-[#d4a843]"
101 placeholder="Confirm your password"
102 required
103 />
104 </div>
105
106 <button
107 type="submit"
108 disabled={loading}
109 className="w-full bg-[#d4a843] text-white font-semibold py-3 rounded-lg hover:bg-[#c49a3a] transition-colors disabled:opacity-50"
110 >
111 {loading ? (
112 <span className="flex items-center justify-center gap-2">
113 <Loader2 className="w-5 h-5 animate-spin" /> Creating account...
114 </span>
115 ) : (
116 'Create Account'
117 )}
118 </button>
119 </form>
120
121 <p className="text-center text-sm text-gray-500">
122 Already have an account?{' '}
123 <Link to="/admin/login" className="text-[#d4a843] font-medium hover:underline">
124 Sign in
125 </Link>
126 </p>
127 </div>
128 </div>
129 </div>
130 )
131}
Modifiedfrontend/src/components/booking/AddressInput.jsx+12−1View fileUnifiedSplit
@@ -3,6 +3,11 @@ import { MapPin, Loader2 } from 'lucide-react'
33import { cn } from '../../lib/cn'
44import api from '../../lib/api'
55
6// Generate a session token per component mount (Google uses this for billing)
7function makeSessionToken() {
8 return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)
9}
10
611export default function AddressInput({ label, value, onChange, placeholder, icon: Icon = MapPin }) {
712 const [query, setQuery] = useState(value || '')
813 const [suggestions, setSuggestions] = useState([])
@@ -10,6 +15,7 @@ export default function AddressInput({ label, value, onChange, placeholder, icon
1015 const [loading, setLoading] = useState(false)
1116 const debounceRef = useRef(null)
1217 const wrapperRef = useRef(null)
18 const sessionRef = useRef(makeSessionToken())
1319
1420 // Close dropdown on outside click
1521 useEffect(() => {
@@ -43,7 +49,9 @@ export default function AddressInput({ label, value, onChange, placeholder, icon
4349 debounceRef.current = setTimeout(async () => {
4450 setLoading(true)
4551 try {
46 const { data } = await api.get('/places/autocomplete', { params: { input: val } })
52 const { data } = await api.get('/places/autocomplete', {
53 params: { input: val, sessiontoken: sessionRef.current },
54 })
4755 setSuggestions(data.predictions || [])
4856 setOpen(true)
4957 } catch {
@@ -60,6 +68,8 @@ export default function AddressInput({ label, value, onChange, placeholder, icon
6068 onChange(desc)
6169 setOpen(false)
6270 setSuggestions([])
71 // New session token after selection (Google bills per session)
72 sessionRef.current = makeSessionToken()
6373 }
6474
6575 return (
@@ -80,6 +90,7 @@ export default function AddressInput({ label, value, onChange, placeholder, icon
8090 'focus:outline-none focus:ring-2 focus:ring-gold/40 focus:border-gold',
8191 'placeholder:text-gray-400 transition-colors'
8292 )}
93 autoComplete="off"
8394 />
8495 {loading && (
8596 <Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 animate-spin" />
Modifiedfrontend/src/components/booking/DateTimePicker.jsx+57−18View fileUnifiedSplit
@@ -1,13 +1,32 @@
1import { useRef } from 'react'
12import { cn } from '../../lib/cn'
23import { Calendar, Clock } from 'lucide-react'
34
45// Generate time slots from 00:00 to 23:30 in 30-min intervals
6// Display in 12-hour AM/PM format, store as 24-hour value
57const TIME_SLOTS = Array.from({ length: 48 }, (_, i) => {
6 const h = Math.floor(i / 2)
8 const h24 = Math.floor(i / 2)
79 const m = i % 2 === 0 ? '00' : '30'
8 return `${String(h).padStart(2, '0')}:${m}`
10 const value = `${String(h24).padStart(2, '0')}:${m}`
11 const period = h24 < 12 ? 'AM' : 'PM'
12 const h12 = h24 === 0 ? 12 : h24 > 12 ? h24 - 12 : h24
13 const label = `${h12}:${m} ${period}`
14 return { value, label }
915})
1016
17function formatDateDisplay(dateStr) {
18 if (!dateStr) return null
19 const [y, m, d] = dateStr.split('-')
20 const date = new Date(y, m - 1, d)
21 return date.toLocaleDateString('en-NZ', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' })
22}
23
24function formatTimeDisplay(timeStr) {
25 if (!timeStr) return null
26 const slot = TIME_SLOTS.find((t) => t.value === timeStr)
27 return slot ? slot.label : timeStr
28}
29
1130export default function DateTimePicker({
1231 dateLabel = 'Pickup Date',
1332 timeLabel = 'Pickup Time',
@@ -17,50 +36,70 @@ export default function DateTimePicker({
1736 onTimeChange,
1837 minDate,
1938}) {
39 const dateRef = useRef(null)
40 const timeRef = useRef(null)
2041 const today = new Date().toISOString().split('T')[0]
2142
2243 return (
2344 <div className="grid grid-cols-2 gap-4">
24 {/* Date */}
45 {/* Date button */}
2546 <div>
2647 <label className="block text-sm font-medium text-gray-700 mb-1.5">{dateLabel}</label>
2748 <div className="relative">
28 <Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
2949 <input
50 ref={dateRef}
3051 type="date"
3152 value={date}
3253 onChange={(e) => onDateChange(e.target.value)}
3354 min={minDate || today}
55 className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
56 tabIndex={-1}
57 />
58 <button
59 type="button"
60 onClick={() => dateRef.current?.showPicker?.() || dateRef.current?.click()}
3461 className={cn(
35 'w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm',
36 'focus:outline-none focus:ring-2 focus:ring-gold/40 focus:border-gold',
37 'transition-colors',
38 !date && 'text-gray-400'
62 'w-full flex items-center gap-2 px-4 py-3 border rounded-lg text-sm font-medium transition-all text-left',
63 date
64 ? 'border-gold bg-gold-50 text-gold'
65 : 'border-gray-300 text-gray-400 hover:border-gray-400'
3966 )}
40 />
67 >
68 <Calendar className="w-5 h-5 shrink-0" />
69 {formatDateDisplay(date) || 'Select date'}
70 </button>
4171 </div>
4272 </div>
4373
44 {/* Time */}
74 {/* Time button */}
4575 <div>
4676 <label className="block text-sm font-medium text-gray-700 mb-1.5">{timeLabel}</label>
4777 <div className="relative">
48 <Clock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" />
4978 <select
79 ref={timeRef}
5080 value={time}
5181 onChange={(e) => onTimeChange(e.target.value)}
52 className={cn(
53 'w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm appearance-none',
54 'focus:outline-none focus:ring-2 focus:ring-gold/40 focus:border-gold',
55 'transition-colors bg-white',
56 !time && 'text-gray-400'
57 )}
82 className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
83 tabIndex={-1}
5884 >
5985 <option value="">Select time</option>
6086 {TIME_SLOTS.map((t) => (
61 <option key={t} value={t}>{t}</option>
87 <option key={t.value} value={t.value}>{t.label}</option>
6288 ))}
6389 </select>
90 <button
91 type="button"
92 onClick={() => timeRef.current?.click()}
93 className={cn(
94 'w-full flex items-center gap-2 px-4 py-3 border rounded-lg text-sm font-medium transition-all text-left',
95 time
96 ? 'border-gold bg-gold-50 text-gold'
97 : 'border-gray-300 text-gray-400 hover:border-gray-400'
98 )}
99 >
100 <Clock className="w-5 h-5 shrink-0" />
101 {formatTimeDisplay(time) || 'Select time'}
102 </button>
64103 </div>
65104 </div>
66105 </div>
Modifiedfrontend/src/components/pages/BookNow.jsx+1−1View fileUnifiedSplit
@@ -397,7 +397,7 @@ export default function BookNow() {
397397 <Row label="Pickup" value={form.pickupAddress} />
398398 {form.pickupAddresses.filter(Boolean).map((a, i) => <Row key={i} label={`Stop ${i + 1}`} value={a} />)}
399399 <Row label="Drop-off" value={form.dropoffAddress} />
400 <Row label="Date & Time" value={`${form.date} at ${form.time}`} />
400 <Row label="Date & Time" value={`${form.date} at ${formatTime12h(form.time)}`} />
401401 <Row label="Passengers" value={form.passengers} />
402402 {form.bookReturn && form.returnDate && <Row label="Return" value={`${form.returnDate} at ${form.returnTime}`} />}
403403 {form.departureFlightNumber && <Row label="Departure Flight" value={form.departureFlightNumber} />}
Modifiedfrontend/vercel.json+8−1View fileUnifiedSplit
@@ -2,6 +2,13 @@
22 "buildCommand": "npm install && npm run build",
33 "outputDirectory": "dist",
44 "rewrites": [
5 { "source": "/(.*)", "destination": "/index.html" }
5 {
6 "source": "/api/:path*",
7 "destination": "https://bookaridev2.onrender.com/api/:path*"
8 },
9 {
10 "source": "/((?!api/).*)",
11 "destination": "/index.html"
12 }
613 ]
714}
Addedscripts/clean_neon_test_data.py+126−0View fileUnifiedSplit
@@ -0,0 +1,126 @@
1"""
2clean_neon_test_data.py
3
4Removes seeded test data from Neon PostgreSQL, keeping only real records
5(admin_users and anything imported from MongoDB).
6
7What it deletes:
8 - All 1,200 seeded bookings (reference numbers 10-1209, created by seed_bookings.py)
9 - Any other obviously fake/test records
10
11What it KEEPS:
12 - admin_users (your 2 real admin accounts)
13 - error_check_reports
14 - password_reset_tokens
15
16Usage (PowerShell):
17 $env:DATABASE_URL="postgresql://..."
18 python scripts\\clean_neon_test_data.py
19
20 # Add --confirm to actually delete (dry-run by default)
21 python scripts\\clean_neon_test_data.py --confirm
22"""
23
24import asyncio
25import os
26import sys
27
28try:
29 import asyncpg
30except ImportError:
31 print("asyncpg not installed. Run: pip install asyncpg")
32 sys.exit(1)
33
34DATABASE_URL = os.environ.get("DATABASE_URL", "")
35DRY_RUN = "--confirm" not in sys.argv
36
37
38async def main():
39 if not DATABASE_URL:
40 print("ERROR: DATABASE_URL not set.")
41 print(" $env:DATABASE_URL='postgresql://...'")
42 sys.exit(1)
43
44 conn = await asyncpg.connect(DATABASE_URL)
45
46 print()
47 print("=" * 55)
48 print(" Neon Test Data Cleanup")
49 print(f" Mode: {'DRY RUN (pass --confirm to delete)' if DRY_RUN else 'LIVE DELETE'}")
50 print("=" * 55)
51
52 try:
53 # ── Check what's in each table ───────────────────────────────────
54 tables = await conn.fetch("""
55 SELECT tablename FROM pg_tables
56 WHERE schemaname = 'public'
57 ORDER BY tablename
58 """)
59
60 print()
61 for row in tables:
62 table = row["tablename"]
63 count = await conn.fetchval(f"SELECT COUNT(*) FROM {table}")
64 print(f" {table:<35} {count:>6} rows")
65
66 # ── Count seeded bookings ─────────────────────────────────────────
67 # Seeded bookings have referenceNumber between 10 and 1209
68 # (seed_bookings.py output: "Reference numbers used: 10 – 1209")
69 try:
70 seeded_count = await conn.fetchval("""
71 SELECT COUNT(*) FROM bookings
72 WHERE (data->>'referenceNumber')::int BETWEEN 10 AND 1209
73 OR data->>'seeded' = 'true'
74 """)
75 except Exception:
76 seeded_count = 0
77
78 try:
79 total_bookings = await conn.fetchval("SELECT COUNT(*) FROM bookings")
80 except Exception:
81 total_bookings = 0
82
83 real_bookings = total_bookings - seeded_count
84
85 print()
86 print(f" Bookings total: {total_bookings}")
87 print(f" Seeded (to delete): {seeded_count}")
88 print(f" Real (to keep): {real_bookings}")
89 print()
90
91 if seeded_count == 0:
92 print(" Nothing to delete — no seeded bookings found.")
93 return
94
95 if DRY_RUN:
96 print(" DRY RUN — no changes made.")
97 print(" Run with --confirm to actually delete.")
98 return
99
100 # ── Delete seeded bookings ────────────────────────────────────────
101 print(" Deleting seeded bookings...")
102 deleted = await conn.execute("""
103 DELETE FROM bookings
104 WHERE (data->>'referenceNumber')::int BETWEEN 10 AND 1209
105 OR data->>'seeded' = 'true'
106 """)
107 count = int(deleted.split()[-1])
108 print(f" Deleted {count} seeded bookings.")
109
110 # ── Final state ───────────────────────────────────────────────────
111 print()
112 print(" Final row counts:")
113 for row in tables:
114 table = row["tablename"]
115 count = await conn.fetchval(f"SELECT COUNT(*) FROM {table}")
116 print(f" {table:<33} {count:>6} rows")
117
118 print()
119 print(" Done. Neon now contains only real data.")
120
121 finally:
122 await conn.close()
123
124
125if __name__ == "__main__":
126 asyncio.run(main())
Addedscripts/find_all_mongo_data.py+129−0View fileUnifiedSplit
@@ -0,0 +1,129 @@
1"""
2find_all_mongo_data.py
3
4Scans ALL databases across up to 3 MongoDB Atlas clusters and reports
5every collection that has data. Run this to locate your missing bookings.
6
7Usage (PowerShell):
8 python scripts\\find_all_mongo_data.py
9
10Edit the CLUSTERS dict below with your connection strings for Cluster1 & Cluster2.
11Get them from: cloud.mongodb.com → your cluster → Connect → Drivers
12"""
13
14import pymongo
15import sys
16from datetime import datetime
17
18# ── EDIT THESE ────────────────────────────────────────────────────────────────
19CLUSTERS = {
20 "Cluster0": "mongodb+srv://bookaride_db:FDP1PLGG37GOT5Id@cluster0.vte8b8.mongodb.net/?authSource=admin&appName=Cluster0",
21 "Cluster1": "PASTE_CLUSTER1_CONNECTION_STRING_HERE",
22 "Cluster2": "PASTE_CLUSTER2_CONNECTION_STRING_HERE",
23}
24
25# Collections we care most about
26KEY_COLLECTIONS = {"bookings", "drivers", "users", "admin_users", "payment_transactions",
27 "bookings_archive", "shuttle_bookings", "hotel_bookings"}
28
29SKIP_DBS = {"admin", "local", "config"}
30# ─────────────────────────────────────────────────────────────────────────────
31
32
33def scan_cluster(name, uri):
34 if "PASTE_" in uri:
35 print(f"\n [{name}] Skipped — no connection string provided")
36 return {}
37
38 print(f"\n{'='*60}")
39 print(f" Scanning {name}...")
40 print(f"{'='*60}")
41
42 try:
43 client = pymongo.MongoClient(uri, serverSelectionTimeoutMS=8000)
44 client.admin.command("ping")
45 except Exception as e:
46 print(f" ERROR connecting: {e}")
47 return {}
48
49 found = {}
50
51 try:
52 for db_name in sorted(client.list_database_names()):
53 if db_name in SKIP_DBS:
54 continue
55
56 db = client[db_name]
57 collections = db.list_collection_names()
58
59 if not collections:
60 continue
61
62 db_has_data = False
63 for col in sorted(collections):
64 try:
65 count = db[col].count_documents({})
66 except Exception:
67 count = 0
68
69 if count == 0:
70 continue
71
72 if not db_has_data:
73 print(f"\n DB: {db_name}")
74 db_has_data = True
75
76 flag = " ◄ BOOKINGS FOUND" if col in KEY_COLLECTIONS else ""
77 print(f" {col:<35} {count:>6} docs{flag}")
78
79 # Show a sample record for key collections
80 if col in KEY_COLLECTIONS and count > 0:
81 sample = db[col].find_one({}, {"_id": 0})
82 if sample:
83 keys = list(sample.keys())[:8]
84 print(f" fields: {', '.join(keys)}")
85 # Show a meaningful field if available
86 for field in ("status", "customerName", "pickupAddress", "email", "name"):
87 if field in sample:
88 print(f" sample {field}: {str(sample[field])[:60]}")
89 break
90
91 found[f"{name}/{db_name}/{col}"] = count
92
93 finally:
94 client.close()
95
96 return found
97
98
99def main():
100 print(f"\nBookARide — MongoDB Cluster Scanner")
101 print(f"Run at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
102
103 all_found = {}
104 for cluster_name, uri in CLUSTERS.items():
105 results = scan_cluster(cluster_name, uri)
106 all_found.update(results)
107
108 print(f"\n{'='*60}")
109 print(f" SUMMARY — Collections with data")
110 print(f"{'='*60}")
111
112 if not all_found:
113 print(" No data found across any cluster.")
114 else:
115 total = 0
116 for path, count in sorted(all_found.items()):
117 col = path.split("/")[-1]
118 flag = " ◄" if col in KEY_COLLECTIONS else ""
119 print(f" {path:<50} {count:>6}{flag}")
120 total += count
121 print(f"\n Total documents: {total}")
122
123 print()
124 print(" Next step: paste this output back to Claude to plan the import.")
125 print()
126
127
128if __name__ == "__main__":
129 main()
Addedscripts/migrate_real_data.py+252−0View fileUnifiedSplit
@@ -0,0 +1,252 @@
1"""
2migrate_real_data.py
3
4Migrates real production data from MongoDB 'Bookaride_db' (capital B)
5into Neon PostgreSQL.
6
7Steps it performs automatically:
8 1. Cleans the 1,200 seeded test bookings from Neon
9 2. Imports all real collections from Bookaride_db
10
11Usage (PowerShell):
12 $env:DATABASE_URL="postgresql://neondb_owner:npg_coP0gWvAdS2N@ep-jolly-queen-aihsx1yx-pooler.c-4.us-east-1.aws.neon.tech/neondb?sslmode=require"
13 $env:MONGO_URL="mongodb+srv://bookaride_db:FDP1PLGG37GOT5Id@cluster0.vte8b8.mongodb.net/Bookaride_db?authSource=admin&appName=Cluster0"
14
15 # Dry run first (no changes made):
16 python scripts\\migrate_real_data.py
17
18 # Actually run it:
19 python scripts\\migrate_real_data.py --confirm
20"""
21
22import asyncio
23import json
24import logging
25import os
26import sys
27from datetime import datetime, date
28
29logging.basicConfig(
30 level=logging.INFO,
31 format="%(asctime)s [%(levelname)s] %(message)s",
32 datefmt="%H:%M:%S",
33)
34log = logging.getLogger(__name__)
35
36try:
37 import asyncpg
38 import pymongo
39except ImportError as e:
40 print(f"Missing package: {e}. Run: pip install asyncpg pymongo")
41 sys.exit(1)
42
43MONGO_URL = os.environ.get(
44 "MONGO_URL",
45 "mongodb+srv://bookaride_db:FDP1PLGG37GOT5Id@cluster0.vte8b8.mongodb.net/Bookaride_db?authSource=admin&appName=Cluster0"
46)
47DATABASE_URL = os.environ.get("DATABASE_URL", "")
48DRY_RUN = "--confirm" not in sys.argv
49
50# Collections to migrate, in order
51# Format: (mongo_collection_name, neon_table_name, description)
52COLLECTIONS = [
53 ("bookings", "bookings", "Real bookings"),
54 ("bookings_archive", "bookings_archive", "Archived bookings"),
55 ("payment_transactions", "payment_transactions", "Payment records"),
56 ("admin_users", "admin_users", "Admin accounts"),
57 ("password_reset_tokens", "password_reset_tokens", "Password tokens"),
58 ("counters", "counters", "ID counters"),
59 ("deleted_bookings", "deleted_bookings", "Deleted bookings"),
60 ("booking_backups", "booking_backups", "Booking backups"),
61 ("seo_pages", "seo_pages", "SEO pages"),
62 ("seo_health_reports", "seo_health_reports", "SEO reports"),
63 ("return_alerts_sent", "return_alerts_sent", "Return alerts"),
64 ("error_check_reports", "error_check_reports", "Error reports"),
65 ("system_tasks", "system_tasks", "System tasks"),
66]
67
68
69def serialize(obj):
70 if isinstance(obj, (datetime, date)):
71 return obj.isoformat()
72 if hasattr(obj, "__str__"):
73 return str(obj)
74 raise TypeError(f"Not serializable: {type(obj)}")
75
76
77def clean_doc(doc: dict) -> dict:
78 """Remove MongoDB-specific fields and make JSON-serializable."""
79 doc.pop("_id", None)
80 return json.loads(json.dumps(doc, default=serialize))
81
82
83async def ensure_table(conn, table: str):
84 await conn.execute(f"""
85 CREATE TABLE IF NOT EXISTS {table} (
86 _id BIGSERIAL PRIMARY KEY,
87 id TEXT UNIQUE,
88 data JSONB NOT NULL DEFAULT '{{}}'::jsonb,
89 created_at TIMESTAMPTZ DEFAULT NOW()
90 )
91 """)
92 await conn.execute(
93 f"CREATE INDEX IF NOT EXISTS idx_{table}_data ON {table} USING GIN (data)"
94 )
95
96
97async def main():
98 if not DATABASE_URL:
99 print("ERROR: DATABASE_URL not set.")
100 print(" $env:DATABASE_URL='postgresql://...'")
101 sys.exit(1)
102
103 log.info("=" * 60)
104 log.info("BookARide: Bookaride_db → Neon Migration")
105 log.info(f"Mode: {'DRY RUN (add --confirm to execute)' if DRY_RUN else 'LIVE'}")
106 log.info("=" * 60)
107
108 # ── Connect to MongoDB ────────────────────────────────────────────────────
109 log.info("\nConnecting to MongoDB Bookaride_db...")
110 try:
111 mongo_client = pymongo.MongoClient(MONGO_URL, serverSelectionTimeoutMS=10000)
112 mongo_db = mongo_client.get_database()
113 mongo_client.admin.command("ping")
114 db_name = mongo_db.name
115 log.info(f" Connected to MongoDB database: {db_name}")
116 except Exception as e:
117 log.error(f" MongoDB connection failed: {e}")
118 sys.exit(1)
119
120 # ── Connect to Neon ───────────────────────────────────────────────────────
121 log.info("Connecting to Neon PostgreSQL...")
122 try:
123 pg = await asyncpg.connect(DATABASE_URL)
124 log.info(" Connected to Neon")
125 except Exception as e:
126 log.error(f" Neon connection failed: {e}")
127 sys.exit(1)
128
129 try:
130 # ── Step 1: Count what's coming ───────────────────────────────────────
131 log.info("\n── What's in Bookaride_db ──────────────────────────────")
132 total_to_import = 0
133 for mongo_col, _, desc in COLLECTIONS:
134 try:
135 count = mongo_db[mongo_col].count_documents({})
136 if count > 0:
137 log.info(f" {mongo_col:<30} {count:>6} docs ({desc})")
138 total_to_import += count
139 except Exception:
140 pass
141 log.info(f"\n Total to import: {total_to_import}")
142
143 # ── Step 2: Clean seeded test bookings from Neon ──────────────────────
144 log.info("\n── Step 1: Clean seeded test data from Neon ────────────")
145 try:
146 seeded = await pg.fetchval("""
147 SELECT COUNT(*) FROM bookings
148 WHERE (data->>'referenceNumber')::int BETWEEN 10 AND 1209
149 """)
150 log.info(f" Seeded test bookings found: {seeded}")
151
152 if not DRY_RUN and seeded > 0:
153 deleted = await pg.execute("""
154 DELETE FROM bookings
155 WHERE (data->>'referenceNumber')::int BETWEEN 10 AND 1209
156 """)
157 log.info(f" Deleted {deleted.split()[-1]} seeded bookings")
158 elif DRY_RUN:
159 log.info(f" (dry run) Would delete {seeded} seeded bookings")
160 except Exception as e:
161 log.warning(f" Could not clean seeded data: {e}")
162
163 # ── Step 3: Import each collection ────────────────────────────────────
164 log.info("\n── Step 2: Import from Bookaride_db ────────────────────")
165 grand_total = 0
166
167 for mongo_col, pg_table, desc in COLLECTIONS:
168 docs = list(mongo_db[mongo_col].find({}))
169 if not docs:
170 continue
171
172 log.info(f"\n {mongo_col} → {pg_table} ({len(docs)} docs)")
173
174 if DRY_RUN:
175 log.info(f" (dry run) Would import {len(docs)} documents")
176 continue
177
178 # Ensure table exists
179 await ensure_table(pg, pg_table)
180
181 inserted = 0
182 skipped = 0
183
184 for doc in docs:
185 clean = clean_doc(doc)
186 doc_id = (
187 clean.get("id") or
188 clean.get("bookingId") or
189 clean.get("referenceNumber") or
190 clean.get("email") or
191 None
192 )
193 if doc_id:
194 doc_id = str(doc_id)
195
196 data_json = json.dumps(clean)
197
198 try:
199 await pg.execute(
200 f"INSERT INTO {pg_table} (id, data) VALUES ($1, $2::jsonb) "
201 f"ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data",
202 doc_id, data_json
203 )
204 inserted += 1
205 except asyncpg.UniqueViolationError:
206 # No unique id — insert without id
207 try:
208 await pg.execute(
209 f"INSERT INTO {pg_table} (data) VALUES ($1::jsonb)",
210 data_json
211 )
212 inserted += 1
213 except Exception as e2:
214 log.warning(f" skip (error): {e2}")
215 skipped += 1
216 except Exception as e:
217 log.warning(f" skip: {e}")
218 skipped += 1
219
220 log.info(f" Imported {inserted}/{len(docs)} (skipped {skipped})")
221 grand_total += inserted
222
223 # ── Step 4: Final summary ─────────────────────────────────────────────
224 log.info("\n── Final Neon State ────────────────────────────────────")
225 tables = await pg.fetch(
226 "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
227 )
228 for row in tables:
229 t = row["tablename"]
230 c = await pg.fetchval(f"SELECT COUNT(*) FROM {t}")
231 log.info(f" {t:<35} {c:>6} rows")
232
233 log.info("\n" + "=" * 60)
234 if DRY_RUN:
235 log.info("DRY RUN complete — no changes were made.")
236 log.info("Run with --confirm to execute the migration.")
237 else:
238 log.info(f"Migration complete! Imported {grand_total} documents.")
239 log.info("\nNext steps:")
240 log.info(" 1. Run: python scripts\\verify_neon_data.py")
241 log.info(" 2. Test V2 admin dashboard and booking flow")
242 log.info(" 3. Update V2 env vars (DATABASE_URL in Render/Vercel)")
243 log.info(" 4. Once confirmed, decommission V1 and MongoDB Atlas")
244 log.info("=" * 60)
245
246 finally:
247 await pg.close()
248 mongo_client.close()
249
250
251if __name__ == "__main__":
252 asyncio.run(main())
Addedscripts/sync-env-to-v2.sh+115−0View fileUnifiedSplit
@@ -0,0 +1,115 @@
1
2# =============================================================================
3# sync-env-to-v2.sh
4#
5# Copies shared environment variables from BookARide V1's .env file into
6# BookARide V2's GitHub repository secrets, so both apps stay in sync.
7#
8# Usage:
9# ./scripts/sync-env-to-v2.sh /path/to/BookARide/.env
10#
11# Requirements:
12# - GitHub CLI (gh) installed and authenticated
13# - gh auth login (if not already done)
14#
15# What gets synced (V1 name → V2 name):
16# Identical names: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET,
17# MAILGUN_API_KEY, MAILGUN_DOMAIN,
18# TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER,
19# GOOGLE_MAPS_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
20#
21# V2-only vars (not touched by this script):
22# DATABASE_URL, JWT_SECRET_KEY, GEOAPIFY_API_KEY
23# =============================================================================
24
25set -euo pipefail
26
27V2_REPO="ccantynz-alt/BookarideV2"
28
29# ── Shared vars to copy (V1 name = V2 name) ──────────────────────────────────
30SHARED_VARS=(
31 STRIPE_SECRET_KEY
32 STRIPE_WEBHOOK_SECRET
33 MAILGUN_API_KEY
34 MAILGUN_DOMAIN
35 TWILIO_ACCOUNT_SID
36 TWILIO_AUTH_TOKEN
37 TWILIO_PHONE_NUMBER
38 GOOGLE_MAPS_API_KEY
39 GOOGLE_CLIENT_ID
40 GOOGLE_CLIENT_SECRET
41 GOOGLE_CALENDAR_ID
42 PUBLIC_DOMAIN
43)
44
45# ── Validate input ────────────────────────────────────────────────────────────
46ENV_FILE="${1:-}"
47if [[ -z "$ENV_FILE" ]]; then
48 echo "Usage: $0 /path/to/BookARide/.env"
49 exit 1
50fi
51
52if [[ ! -f "$ENV_FILE" ]]; then
53 echo "Error: .env file not found at: $ENV_FILE"
54 exit 1
55fi
56
57if ! command -v gh &>/dev/null; then
58 echo "Error: GitHub CLI (gh) is not installed."
59 echo "Install it from: https://cli.github.com"
60 exit 1
61fi
62
63echo "Reading from: $ENV_FILE"
64echo "Syncing to: $V2_REPO"
65echo ""
66
67# ── Parse .env file into associative array ────────────────────────────────────
68declare -A env_values
69
70while IFS= read -r line || [[ -n "$line" ]]; do
71 # Skip comments and blank lines
72 [[ "$line" =~ ^[[:space:]]*# ]] && continue
73 [[ -z "${line// }" ]] && continue
74
75 # Split on first =
76 key="${line%%=*}"
77 value="${line#*=}"
78
79 # Strip surrounding quotes from value
80 value="${value%\"}"
81 value="${value#\"}"
82 value="${value%\'}"
83 value="${value#\'}"
84
85 key="${key// /}" # trim whitespace from key
86 env_values["$key"]="$value"
87done < "$ENV_FILE"
88
89# ── Sync each shared var ──────────────────────────────────────────────────────
90synced=0
91skipped=0
92
93for var in "${SHARED_VARS[@]}"; do
94 if [[ -n "${env_values[$var]+_}" ]]; then
95 val="${env_values[$var]}"
96 if [[ -z "$val" ]]; then
97 echo " SKIP $var (empty in V1)"
98 ((skipped++))
99 continue
100 fi
101 echo -n " SET $var ... "
102 echo -n "$val" | gh secret set "$var" --repo "$V2_REPO"
103 echo "done"
104 ((synced++))
105 else
106 echo " SKIP $var (not found in V1 .env)"
107 ((skipped++))
108 fi
109done
110
111echo ""
112echo "Done. Synced: $synced Skipped: $skipped"
113echo ""
114echo "Note: DATABASE_URL, JWT_SECRET_KEY, and GEOAPIFY_API_KEY are V2-only"
115echo " and must be set separately in V2's GitHub secrets."
Addedscripts/verify_neon_data.py+161−0View fileUnifiedSplit
@@ -0,0 +1,161 @@
1#!/usr/bin/env python3
2"""
3verify_neon_data.py
4
5Connects to your Neon PostgreSQL database and prints a report of:
6 - Every table that exists
7 - Row count per table
8 - The most recent 3 records from key collections (bookings, users, drivers)
9 - Any obviously empty tables that should have data
10
11Usage:
12 export DATABASE_URL="postgresql://..."
13 python3 scripts/verify_neon_data.py
14
15 # Or inline:
16 DATABASE_URL="postgresql://..." python3 scripts/verify_neon_data.py
17"""
18
19import asyncio
20import json
21import os
22import sys
23from datetime import datetime
24
25try:
26 import asyncpg
27except ImportError:
28 print("asyncpg not installed. Run: pip install asyncpg")
29 sys.exit(1)
30
31DATABASE_URL = os.environ.get("DATABASE_URL", "")
32
33# Collections we expect to have data after migration from V1
34EXPECTED_TABLES = ["bookings", "users", "drivers", "pricing", "admin_users"]
35
36# Fields to display as a summary per record
37PREVIEW_FIELDS = {
38 "bookings": ["id", "status", "customerName", "pickupAddress", "createdAt", "totalPrice"],
39 "users": ["id", "name", "email", "createdAt"],
40 "drivers": ["id", "name", "email", "status"],
41 "admin_users": ["id", "username", "email"],
42}
43
44
45def fmt(val):
46 if val is None:
47 return "—"
48 if isinstance(val, str) and len(val) > 60:
49 return val[:57] + "..."
50 return str(val)
51
52
53async def main():
54 if not DATABASE_URL:
55 print("ERROR: DATABASE_URL environment variable is not set.")
56 print(" export DATABASE_URL='postgresql://user:pass@host/db?sslmode=require'")
57 sys.exit(1)
58
59 print(f"\n{'='*65}")
60 print(" Neon Database Verification Report")
61 print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
62 print(f"{'='*65}\n")
63
64 try:
65 conn = await asyncpg.connect(DATABASE_URL)
66 except Exception as e:
67 print(f"ERROR: Could not connect to Neon database.\n {e}")
68 sys.exit(1)
69
70 try:
71 # ── List all tables ──────────────────────────────────────────────
72 rows = await conn.fetch("""
73 SELECT tablename
74 FROM pg_tables
75 WHERE schemaname = 'public'
76 ORDER BY tablename
77 """)
78 all_tables = [r["tablename"] for r in rows]
79
80 if not all_tables:
81 print(" No tables found. The database appears to be empty.")
82 print(" Run the schema setup or check your DATABASE_URL.\n")
83 return
84
85 # ── Row counts ───────────────────────────────────────────────────
86 print(f" {'TABLE':<25} {'ROWS':>8} STATUS")
87 print(f" {'-'*25} {'-'*8} {'-'*20}")
88
89 table_counts = {}
90 for table in all_tables:
91 try:
92 count = await conn.fetchval(f"SELECT COUNT(*) FROM {table}")
93 except Exception:
94 count = -1
95 table_counts[table] = count
96
97 status = ""
98 if table in EXPECTED_TABLES and count == 0:
99 status = "⚠ EMPTY — expected data here"
100 elif table in EXPECTED_TABLES:
101 status = "✓"
102
103 count_str = str(count) if count >= 0 else "error"
104 print(f" {table:<25} {count_str:>8} {status}")
105
106 # ── Missing expected tables ──────────────────────────────────────
107 missing = [t for t in EXPECTED_TABLES if t not in all_tables]
108 if missing:
109 print()
110 for t in missing:
111 print(f" {'?':<25} {'N/A':>8} ✗ TABLE MISSING — '{t}' not created yet")
112
113 # ── Preview key collections ──────────────────────────────────────
114 for table, fields in PREVIEW_FIELDS.items():
115 if table not in all_tables:
116 continue
117 count = table_counts.get(table, 0)
118 if count == 0:
119 continue
120
121 print(f"\n --- Last 3 records in '{table}' ---")
122 try:
123 sample_rows = await conn.fetch(
124 f"SELECT data FROM {table} ORDER BY _id DESC LIMIT 3"
125 )
126 except Exception as e:
127 print(f" (could not query: {e})")
128 continue
129
130 for i, row in enumerate(sample_rows, 1):
131 try:
132 doc = json.loads(row["data"]) if isinstance(row["data"], str) else dict(row["data"])
133 except Exception:
134 doc = {}
135 summary = " " + " ".join(
136 f"{f}={fmt(doc.get(f))}" for f in fields if doc.get(f) is not None
137 )
138 print(f" [{i}] {summary}")
139
140 # ── Summary ──────────────────────────────────────────────────────
141 total_rows = sum(c for c in table_counts.values() if c >= 0)
142 print(f"\n{'='*65}")
143 print(f" Total tables: {len(all_tables)} Total rows: {total_rows}")
144 if missing:
145 print(f" Missing tables: {', '.join(missing)}")
146 empty_expected = [t for t in EXPECTED_TABLES if table_counts.get(t, -1) == 0]
147 if empty_expected:
148 print(f" Empty (but expected to have data): {', '.join(empty_expected)}")
149 print()
150 print(" If these should have data, the MongoDB migration may be incomplete.")
151 print(" Check your migration tool's logs or re-run the import.")
152 else:
153 print(" All expected tables have data. Transfer looks complete.")
154 print(f"{'='*65}\n")
155
156 finally:
157 await conn.close()
158
159
160if __name__ == "__main__":
161 asyncio.run(main())
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts