feat: implement hero section with multi-trip booking widget and backend infrastructure
This commit is contained in:
parent
44995cb5db
commit
e7365b06ac
21 changed files with 4104 additions and 113 deletions
23
.env
Normal file
23
.env
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
|
||||||
|
|
||||||
|
# This was inserted by `prisma init`:
|
||||||
|
# Environment variables declared in this file are NOT automatically loaded by Prisma.
|
||||||
|
# Please add `import "dotenv/config";` to your `prisma.config.ts` file, or use the Prisma CLI with Bun
|
||||||
|
# to load environment variables from .env files: https://pris.ly/prisma-config-env-vars.
|
||||||
|
|
||||||
|
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||||
|
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||||
|
|
||||||
|
# The following `prisma+postgres` URL is similar to the URL produced by running a local Prisma Postgres
|
||||||
|
# server with the `prisma dev` CLI command, when not choosing any non-default ports or settings. The API key, unlike the
|
||||||
|
# one found in a remote Prisma Postgres URL, does not contain any sensitive information.
|
||||||
|
|
||||||
|
# Replace 'YOUR_PASSWORD_HERE' with your actual MySQL Root Password
|
||||||
|
DATABASE_URL="mysql://root:Z41kfdw8e8%40sql@localhost:3306/roadrentals"
|
||||||
|
|
||||||
|
# Individual DB vars for the Express server (mariadb driver)
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=root
|
||||||
|
DB_PASSWORD=Z41kfdw8e8@sql
|
||||||
|
DB_NAME=roadrentals
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -22,3 +22,5 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|
|
||||||
386
backend/index.js
Normal file
386
backend/index.js
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
import "dotenv/config";
|
||||||
|
import express from "express";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
// Create a connection pool to MySQL
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || "localhost",
|
||||||
|
user: process.env.DB_USER || "root",
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || "roadrentals",
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const checkToken = (req, res, next) => {
|
||||||
|
console.log("Welcome");
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
app.use(checkToken);
|
||||||
|
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.json({ status: 1, message: "Home Page API" });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/news", (req, res) => {
|
||||||
|
res.json({ status: 1, message: "News API" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Database Connected Endpoint
|
||||||
|
app.post("/api/auth/register", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, email, mobile_number, password } = req.body;
|
||||||
|
|
||||||
|
if (!name || !email || !mobile_number || !password) {
|
||||||
|
return res.status(400).json({ detail: "Missing required fields" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search the MySQL Database
|
||||||
|
const [users] = await pool.execute("SELECT * FROM User WHERE email = ?", [email]);
|
||||||
|
const existingUser = users[0];
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ detail: "Email already registered" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new user row into MySQL table
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
"INSERT INTO User (name, email, mobile_number, password) VALUES (?, ?, ?, ?)",
|
||||||
|
[name, email, mobile_number, password]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: result.insertId,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
mobile_number
|
||||||
|
};
|
||||||
|
|
||||||
|
return res.status(201).json({ status: 1, message: "User registered successfully", user: newUser });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Database Error:", error);
|
||||||
|
return res.status(500).json({ detail: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a New Booking
|
||||||
|
app.post("/api/bookings", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
tripType,
|
||||||
|
pickupLocation,
|
||||||
|
dropLocation,
|
||||||
|
pickupDateTime,
|
||||||
|
returnDateTime,
|
||||||
|
packageType,
|
||||||
|
carName,
|
||||||
|
totalAmount
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!tripType || !pickupLocation || !pickupDateTime) {
|
||||||
|
return res.status(400).json({ detail: "Missing required booking details" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.execute(
|
||||||
|
`INSERT INTO Bookings (trip_type, pickup_location, drop_location, pickup_datetime, return_datetime, package_type, car_name, total_amount)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
tripType,
|
||||||
|
pickupLocation,
|
||||||
|
dropLocation || null,
|
||||||
|
new Date(pickupDateTime),
|
||||||
|
returnDateTime ? new Date(returnDateTime) : null,
|
||||||
|
packageType || null,
|
||||||
|
carName || null,
|
||||||
|
totalAmount || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
status: 1,
|
||||||
|
message: "Booking requested successfully!",
|
||||||
|
bookingId: result.insertId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Booking Error:", error);
|
||||||
|
return res.status(500).json({ detail: "Failed to create booking" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pricing Engine & Car Data
|
||||||
|
const VEHICLES = [
|
||||||
|
// LUXURY TIER
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Audi A6 Premium",
|
||||||
|
type: "LUXURY",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "audi_a6",
|
||||||
|
baseFarePerDay: 12000,
|
||||||
|
driverAllowancePerDay: 600,
|
||||||
|
extraKmRate: 45,
|
||||||
|
includedKmPerDay: 250,
|
||||||
|
badge: "Executive",
|
||||||
|
features: ["Leather Interior", "Dual Climate", "Premium Audio", "Sunroof"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "BMW 7 Series",
|
||||||
|
type: "LUXURY",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "3 Bags",
|
||||||
|
image: "bmw_7_series",
|
||||||
|
baseFarePerDay: 25000,
|
||||||
|
driverAllowancePerDay: 800,
|
||||||
|
extraKmRate: 85,
|
||||||
|
includedKmPerDay: 200,
|
||||||
|
badge: "VVIP Choice",
|
||||||
|
features: ["Rear-seat Entertainment", "Massage Seats", "Quiet Cabin", "Chauffeur Driven"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Mercedes S-Class",
|
||||||
|
type: "LUXURY",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "mercedes_s_class",
|
||||||
|
baseFarePerDay: 28000,
|
||||||
|
driverAllowancePerDay: 800,
|
||||||
|
extraKmRate: 90,
|
||||||
|
includedKmPerDay: 200,
|
||||||
|
badge: "Ultimate Luxury",
|
||||||
|
features: ["Ambient Lighting", "Burmester Sound", "Premium Chauffeur"]
|
||||||
|
},
|
||||||
|
// SUV/MUV TIER
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Toyota Fortuner 4x4",
|
||||||
|
type: "SUV",
|
||||||
|
seats: "6+1",
|
||||||
|
luggage: "4 Bags",
|
||||||
|
image: "fortuner",
|
||||||
|
baseFarePerDay: 6500,
|
||||||
|
driverAllowancePerDay: 500,
|
||||||
|
extraKmRate: 25,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Advanture King",
|
||||||
|
features: ["Off-road capable", "Powerful AC", "Spacious", "Rugged Built"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Innova Crysta",
|
||||||
|
type: "MUV",
|
||||||
|
seats: "7+1",
|
||||||
|
luggage: "3 Bags",
|
||||||
|
image: "innova_crysta",
|
||||||
|
baseFarePerDay: 4500,
|
||||||
|
driverAllowancePerDay: 400,
|
||||||
|
extraKmRate: 18,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Best Seller",
|
||||||
|
features: ["Clean Interiors", "Captain Seats", "Smooth Ride", "Carrier Available"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
name: "Toyota Innova",
|
||||||
|
type: "MUV",
|
||||||
|
seats: "7+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "innova",
|
||||||
|
baseFarePerDay: 3800,
|
||||||
|
driverAllowancePerDay: 400,
|
||||||
|
extraKmRate: 16,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Value MUV",
|
||||||
|
features: ["Comfortable", "Spacious", "Economical for Family"]
|
||||||
|
},
|
||||||
|
// SEDAN TIER
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
name: "Toyota Camry",
|
||||||
|
type: "SEDAN",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "camry",
|
||||||
|
baseFarePerDay: 4200,
|
||||||
|
driverAllowancePerDay: 400,
|
||||||
|
extraKmRate: 18,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Corporate Elite",
|
||||||
|
features: ["Hybrid Tech", "Quiet Ride", "Executive Space"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
name: "Toyota Etios",
|
||||||
|
type: "SEDAN",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "etios",
|
||||||
|
baseFarePerDay: 2800,
|
||||||
|
driverAllowancePerDay: 400,
|
||||||
|
extraKmRate: 13,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Economic",
|
||||||
|
features: ["Spacious Boot", "Good Legroom", "Standard AC"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
name: "Swift Dzire",
|
||||||
|
type: "SEDAN",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "dzire",
|
||||||
|
baseFarePerDay: 2600,
|
||||||
|
driverAllowancePerDay: 400,
|
||||||
|
extraKmRate: 13,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Economic Choice",
|
||||||
|
features: ["Popular", "Quick Maneuver", "Efficient AC"]
|
||||||
|
},
|
||||||
|
// GROUP TRAVEL
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
name: "Audi Q7 Luxury SUV",
|
||||||
|
type: "SUV",
|
||||||
|
seats: "6+1",
|
||||||
|
luggage: "4 Bags",
|
||||||
|
image: "audi_q7",
|
||||||
|
baseFarePerDay: 15000,
|
||||||
|
driverAllowancePerDay: 700,
|
||||||
|
extraKmRate: 55,
|
||||||
|
includedKmPerDay: 250,
|
||||||
|
badge: "Luxury SUV",
|
||||||
|
features: ["Quattro 4x4", "Panoramic Sunroof", "Premium Audio", "Heated Seats"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
name: "BMW 5 Series",
|
||||||
|
type: "LUXURY",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "bmw_5_series",
|
||||||
|
baseFarePerDay: 14000,
|
||||||
|
driverAllowancePerDay: 600,
|
||||||
|
extraKmRate: 50,
|
||||||
|
includedKmPerDay: 250,
|
||||||
|
badge: "Business Class",
|
||||||
|
features: ["Dynamic Drive", "Luxury Interiors", "Silent Hybrid", "Touchscreen Control"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 13,
|
||||||
|
name: "Mercedes E-Class",
|
||||||
|
type: "LUXURY",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "mercedes_e_class",
|
||||||
|
baseFarePerDay: 14500,
|
||||||
|
driverAllowancePerDay: 600,
|
||||||
|
extraKmRate: 52,
|
||||||
|
includedKmPerDay: 250,
|
||||||
|
badge: "Timeless Elegance",
|
||||||
|
features: ["Soft Close Doors", "Ambient Light", "Professional Driver", "WIFI Enabled"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
|
name: "Toyota Corolla Altis",
|
||||||
|
type: "SEDAN",
|
||||||
|
seats: "4+1",
|
||||||
|
luggage: "2 Bags",
|
||||||
|
image: "corolla_altis",
|
||||||
|
baseFarePerDay: 3800,
|
||||||
|
driverAllowancePerDay: 400,
|
||||||
|
extraKmRate: 16,
|
||||||
|
includedKmPerDay: 300,
|
||||||
|
badge: "Executive Sedan",
|
||||||
|
features: ["Durable", "Comfortable Suspension", "Clean Air Filter"]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Lead Capture
|
||||||
|
app.post("/api/leads", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, mobile_number, tripType, pickupLocation, dropLocation } = req.body;
|
||||||
|
if (!name || !mobile_number) return res.status(400).json({ detail: "Name and Mobile are required" });
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
`INSERT INTO Leads (name, mobile_number, trip_type, pickup_location, drop_location) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[name, mobile_number, tripType || null, pickupLocation || null, dropLocation || null]
|
||||||
|
);
|
||||||
|
res.status(201).json({ status: 1, message: "Lead captured" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lead Error:", error);
|
||||||
|
res.status(500).json({ detail: "Failed to capture lead" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Centralized Pricing Calculator
|
||||||
|
app.post("/api/pricing/calculate", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tripType, pickupDate, returnDate, packageType } = req.body;
|
||||||
|
|
||||||
|
const start = new Date(pickupDate);
|
||||||
|
const end = returnDate ? new Date(returnDate) : start;
|
||||||
|
const diffTime = Math.abs(end - start);
|
||||||
|
const durationDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) || 1;
|
||||||
|
|
||||||
|
const results = VEHICLES.map(vehicle => {
|
||||||
|
let totalBaseFare = vehicle.baseFarePerDay * durationDays;
|
||||||
|
let totalDriverAllowance = vehicle.driverAllowancePerDay * durationDays;
|
||||||
|
|
||||||
|
// Special Case: Local Packages
|
||||||
|
if (tripType === 'local' && packageType) {
|
||||||
|
if (packageType.includes("4Hr")) totalBaseFare = 1500;
|
||||||
|
else if (packageType.includes("8Hr")) totalBaseFare = 2500;
|
||||||
|
else if (packageType.includes("12Hr")) totalBaseFare = 3500;
|
||||||
|
totalDriverAllowance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special Case: Airport Transfers (Flat Rates)
|
||||||
|
if (tripType === 'airport') {
|
||||||
|
if (vehicle.type === 'SEDAN') totalBaseFare = 1200;
|
||||||
|
else if (vehicle.type === 'SUV') totalBaseFare = 2200;
|
||||||
|
else totalBaseFare = 1800; // MUV/Other
|
||||||
|
totalDriverAllowance = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grandTotal = totalBaseFare + totalDriverAllowance;
|
||||||
|
const advancePayment = Math.round(grandTotal * 0.20);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...vehicle,
|
||||||
|
calc: {
|
||||||
|
days: durationDays,
|
||||||
|
baseFare: totalBaseFare,
|
||||||
|
driverAllowance: totalDriverAllowance,
|
||||||
|
totalKmIncluded: vehicle.includedKmPerDay * (tripType === 'airport' ? 0.2 : durationDays), // Approx for airport
|
||||||
|
grandTotal,
|
||||||
|
advancePayment,
|
||||||
|
math: tripType === 'airport' || tripType === 'local'
|
||||||
|
? `Fixed ${tripType.charAt(0).toUpperCase() + tripType.slice(1)} Rate`
|
||||||
|
: `(₹${vehicle.baseFarePerDay} x ${durationDays} Days) + ₹${totalDriverAllowance} Driver`
|
||||||
|
},
|
||||||
|
inclusions: ["AC", "Professional Driver", tripType === 'airport' ? "Airport Toll Included" : "First " + (vehicle.includedKmPerDay * durationDays) + " Kms"],
|
||||||
|
exclusions: ["Parking Charges", "Extra Waiting Time", "Inter-state Permits"]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 1, data: results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Pricing Error:", error);
|
||||||
|
res.status(500).json({ detail: "Failed to calculate pricing" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = 8000;
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
2633
package-lock.json
generated
2633
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -7,12 +7,20 @@
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"start-backend": "node backend/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
"dotenv": "^17.4.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"lodash": "^4.18.1",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"mysql2": "^3.20.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-datepicker": "^9.1.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|
@ -25,7 +33,9 @@
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.4.0",
|
"globals": "^17.4.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
|
"prisma": "^7.6.0",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"vite": "^8.0.1"
|
"vite": "^8.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
12
prisma/migrations/20260403123931_init/migration.sql
Normal file
12
prisma/migrations/20260403123931_init/migration.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `User` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NOT NULL,
|
||||||
|
`phone` VARCHAR(191) NOT NULL,
|
||||||
|
`password` VARCHAR(191) NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
|
||||||
|
UNIQUE INDEX `User_email_key`(`email`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "mysql"
|
||||||
22
prisma/schema.prisma
Normal file
22
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
// Get a free hosted Postgres database in seconds: `npx create-db`
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String
|
||||||
|
email String @unique
|
||||||
|
phone String
|
||||||
|
password String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
39
scripts/create_bookings_table.js
Normal file
39
scripts/create_bookings_table.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || "localhost",
|
||||||
|
user: process.env.DB_USER || "root",
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || "roadrentals"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Creating Bookings table...");
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS Bookings (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
trip_type VARCHAR(50) NOT NULL,
|
||||||
|
pickup_location TEXT NOT NULL,
|
||||||
|
drop_location TEXT,
|
||||||
|
pickup_datetime DATETIME NOT NULL,
|
||||||
|
return_datetime DATETIME,
|
||||||
|
package_type VARCHAR(100),
|
||||||
|
car_name VARCHAR(100),
|
||||||
|
total_amount DECIMAL(10, 2),
|
||||||
|
status VARCHAR(20) DEFAULT 'pending',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("Bookings table successfully created or already exists!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error creating table:", e.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
35
scripts/create_leads_table.js
Normal file
35
scripts/create_leads_table.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || "localhost",
|
||||||
|
user: process.env.DB_USER || "root",
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || "roadrentals"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Creating Leads table...");
|
||||||
|
|
||||||
|
await pool.execute(`
|
||||||
|
CREATE TABLE IF NOT EXISTS Leads (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
mobile_number VARCHAR(15) NOT NULL,
|
||||||
|
trip_type VARCHAR(50),
|
||||||
|
pickup_location TEXT,
|
||||||
|
drop_location TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log("Leads table successfully created!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error creating table:", e.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
23
scripts/migrate_mobile_number.js
Normal file
23
scripts/migrate_mobile_number.js
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || "localhost",
|
||||||
|
user: process.env.DB_USER || "root",
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME || "roadrentals"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Renaming column 'phone' to 'mobile_number'...");
|
||||||
|
await pool.execute("ALTER TABLE User CHANGE COLUMN phone mobile_number VARCHAR(255) NOT NULL");
|
||||||
|
console.log("Column successfully renamed!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error modifying table:", e.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
63
src/App.jsx
63
src/App.jsx
|
|
@ -1,13 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Navbar from "./components/Navbar/Navbar";
|
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
|
||||||
import HeroSection from "./components/Hero/HeroSection";
|
import HomePage from "./pages/HomePage";
|
||||||
import CabsSection from "./components/Cabs/CabsSection";
|
import SearchPage from "./pages/SearchPage";
|
||||||
import ServicesSection from "./components/Services/ServicesSection";
|
|
||||||
import PromoBanners from "./components/Promotions/PromoBanners";
|
|
||||||
import ReviewsSection from "./components/Reviews/ReviewsSection";
|
|
||||||
import WhyChooseUs from "./components/Highlights/WhyChooseUs";
|
|
||||||
import NewsSection from "./components/News/NewsSection";
|
|
||||||
import Footer from "./components/Footer/Footer";
|
|
||||||
import AuthModal from "./components/Auth/AuthModal";
|
import AuthModal from "./components/Auth/AuthModal";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
|
|
@ -16,26 +10,37 @@ function App() {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<Router>
|
||||||
<Navbar
|
<main className="min-h-screen">
|
||||||
user={user}
|
<Routes>
|
||||||
onLoginClick={() => setIsAuthModalOpen(true)}
|
<Route
|
||||||
/>
|
path="/"
|
||||||
<HeroSection />
|
element={
|
||||||
<CabsSection />
|
<HomePage
|
||||||
<ServicesSection />
|
onLoginClick={() => setIsAuthModalOpen(true)}
|
||||||
<PromoBanners />
|
user={user}
|
||||||
<ReviewsSection />
|
/>
|
||||||
<WhyChooseUs />
|
}
|
||||||
<NewsSection />
|
/>
|
||||||
<Footer />
|
<Route
|
||||||
<AuthModal
|
path="/search"
|
||||||
isOpen={isAuthModalOpen}
|
element={
|
||||||
onClose={() => setIsAuthModalOpen(false)}
|
<SearchPage
|
||||||
user={user}
|
onLoginClick={() => setIsAuthModalOpen(true)}
|
||||||
setUser={setUser}
|
user={user}
|
||||||
/>
|
/>
|
||||||
</main>
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
|
||||||
|
<AuthModal
|
||||||
|
isOpen={isAuthModalOpen}
|
||||||
|
onClose={() => setIsAuthModalOpen(false)}
|
||||||
|
user={user}
|
||||||
|
setUser={setUser}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const PhoneInputScreen = ({ onRequestOtp, onCancel, onGoogleSuccess, onRegisterS
|
||||||
// Register Form State
|
// Register Form State
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [phone, setPhone] = useState("");
|
const [mobile_number, setMobileNumber] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
const [isRegistering, setIsRegistering] = useState(false);
|
const [isRegistering, setIsRegistering] = useState(false);
|
||||||
|
|
@ -20,7 +20,7 @@ const PhoneInputScreen = ({ onRequestOtp, onCancel, onGoogleSuccess, onRegisterS
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
if (!name || !email || !phone || !password) {
|
if (!name || !email || !mobile_number || !password) {
|
||||||
setErrorMsg("Please fill all fields.");
|
setErrorMsg("Please fill all fields.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -33,12 +33,12 @@ const PhoneInputScreen = ({ onRequestOtp, onCancel, onGoogleSuccess, onRegisterS
|
||||||
const response = await api.post("/api/auth/register", {
|
const response = await api.post("/api/auth/register", {
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
phone,
|
mobile_number,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pass the successful name/email down the line dynamically
|
// Pass the successful name/email down the line dynamically
|
||||||
onRegisterSuccess({ name: response.user?.name || name, email: response.user?.email || email, phone: response.user?.phone || phone });
|
onRegisterSuccess({ name: response.user?.name || name, email: response.user?.email || email, mobile_number: response.user?.mobile_number || mobile_number });
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message.includes("409") || err.message.toLowerCase().includes("conflict") || err.message.toLowerCase().includes("already registered")) {
|
if (err.message.includes("409") || err.message.toLowerCase().includes("conflict") || err.message.toLowerCase().includes("already registered")) {
|
||||||
|
|
@ -163,8 +163,8 @@ const PhoneInputScreen = ({ onRequestOtp, onCancel, onGoogleSuccess, onRegisterS
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="Mobile Number"
|
placeholder="Mobile Number"
|
||||||
value={phone}
|
value={mobile_number}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => setMobileNumber(e.target.value)}
|
||||||
className="flex-1 w-full outline-none px-4 py-3.5 text-sm font-semibold rounded-r-xl placeholder:font-normal"
|
className="flex-1 w-full outline-none px-4 py-3.5 text-sm font-semibold rounded-r-xl placeholder:font-normal"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
120
src/components/Hero/DateTimePicker.jsx
Normal file
120
src/components/Hero/DateTimePicker.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDatePicker from "react-datepicker";
|
||||||
|
import "react-datepicker/dist/react-datepicker.css";
|
||||||
|
import { Calendar, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
// Custom theme for react-datepicker
|
||||||
|
const calendarStyles = `
|
||||||
|
.react-datepicker {
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.98) !important;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
display: flex !important;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 280px !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.react-datepicker__header {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.react-datepicker__month-container {
|
||||||
|
float: none !important;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.react-datepicker__time-container {
|
||||||
|
float: none !important;
|
||||||
|
width: 85px !important;
|
||||||
|
border-left: 1px solid #f1f5f9;
|
||||||
|
height: 280px !important;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.react-datepicker__time-box {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 240px !important; /* Avoid header height */
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__time-list {
|
||||||
|
padding: 0 !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
height: calc(280px - 40px) !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__time-list-item {
|
||||||
|
height: auto !important;
|
||||||
|
padding: 10px 0 !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.react-datepicker__time-list-item:hover {
|
||||||
|
background: #e2e8f0 !important;
|
||||||
|
color: #4f46e5 !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__time-list-item--selected {
|
||||||
|
background-color: #4f46e5 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__day-name, .react-datepicker__day {
|
||||||
|
width: 2.2rem;
|
||||||
|
line-height: 2.2rem;
|
||||||
|
margin: 0.1rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b !important;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.react-datepicker__day--selected {
|
||||||
|
background-color: #4f46e5 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__day--keyboard-selected {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #4f46e5 !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__current-month, .react-datepicker__time-header {
|
||||||
|
font-weight: 800 !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
.react-datepicker__navigation {
|
||||||
|
top: 1rem;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DateTimePicker = ({ label, selected, onChange, placeholder, minDate = new Date() }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[200px] px-4 py-3 relative">
|
||||||
|
<style>{calendarStyles}</style>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-dark-700 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-[11px] text-badge-text font-medium leading-none mb-1">{label}</p>
|
||||||
|
<div className="relative group">
|
||||||
|
<ReactDatePicker
|
||||||
|
selected={selected}
|
||||||
|
onChange={onChange}
|
||||||
|
showTimeSelect
|
||||||
|
dateFormat="MMMM d, yyyy h:mm aa"
|
||||||
|
minDate={minDate}
|
||||||
|
placeholderText={placeholder}
|
||||||
|
className="text-sm font-semibold text-dark-900 placeholder:text-gray-400 bg-transparent border-none outline-none w-full cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DateTimePicker;
|
||||||
|
|
@ -1,34 +1,30 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import heroBg from "../../assets/hero_bg.png";
|
import heroBg from "../../assets/hero_bg.png";
|
||||||
|
import LocationAutocomplete from "./LocationAutocomplete";
|
||||||
|
import DateTimePicker from "./DateTimePicker";
|
||||||
|
import { ArrowRight, Plane, Navigation, Repeat, MapPin } from "lucide-react";
|
||||||
|
|
||||||
const tripTypes = [
|
const tripTypes = [
|
||||||
{
|
{
|
||||||
key: "round",
|
key: "round",
|
||||||
label: "Round Trip",
|
label: "Round Trip",
|
||||||
icon: (
|
icon: <Repeat className="w-4 h-4" />,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 2l4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/></svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "oneway",
|
key: "oneway",
|
||||||
label: "One Way",
|
label: "One Way",
|
||||||
icon: (
|
icon: <ArrowRight className="w-4 h-4" />,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "local",
|
key: "local",
|
||||||
label: "Local Trip",
|
label: "Local Trip",
|
||||||
icon: (
|
icon: <Navigation className="w-4 h-4" />,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "airport",
|
key: "airport",
|
||||||
label: "Airport Trip",
|
label: "Airport Trip",
|
||||||
icon: (
|
icon: <Plane className="w-4 h-4" />,
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z"/></svg>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -39,41 +35,108 @@ const localPackages = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const HeroSection = () => {
|
const HeroSection = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [activeTrip, setActiveTrip] = useState("round");
|
const [activeTrip, setActiveTrip] = useState("round");
|
||||||
|
const [pickupLocation, setPickupLocation] = useState("");
|
||||||
|
const [dropLocation, setDropLocation] = useState("");
|
||||||
|
const [pickupDate, setPickupDate] = useState(null);
|
||||||
|
const [returnDate, setReturnDate] = useState(null);
|
||||||
const [selectedPackage, setSelectedPackage] = useState(localPackages[0]);
|
const [selectedPackage, setSelectedPackage] = useState(localPackages[0]);
|
||||||
|
const [airportName, setAirportName] = useState("");
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (!pickupLocation) {
|
||||||
|
alert("Please enter a pickup location.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
tripType: activeTrip,
|
||||||
|
pickup: pickupLocation,
|
||||||
|
drop: dropLocation || airportName || "",
|
||||||
|
pickupDate: pickupDate?.toISOString() || "",
|
||||||
|
returnDate: returnDate?.toISOString() || "",
|
||||||
|
package: activeTrip === 'local' ? selectedPackage : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate(`/search?${searchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
const getFormFields = () => {
|
const getFormFields = () => {
|
||||||
switch (activeTrip) {
|
switch (activeTrip) {
|
||||||
case "round":
|
case "round":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField icon="location" label="From City" placeholder="Enter Pickup City" />
|
<LocationAutocomplete
|
||||||
|
label="From City"
|
||||||
|
placeholder="Enter Pickup City"
|
||||||
|
value={pickupLocation}
|
||||||
|
onChange={setPickupLocation}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="destination" label="To City" placeholder="Enter Drop Location" />
|
<LocationAutocomplete
|
||||||
|
label="To City"
|
||||||
|
placeholder="Enter Drop Location"
|
||||||
|
value={dropLocation}
|
||||||
|
icon="destination"
|
||||||
|
onChange={setDropLocation}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="calendar" label="Pickup date & time" placeholder="Select Date" />
|
<DateTimePicker
|
||||||
|
label="Pickup date & time"
|
||||||
|
placeholder="Select Date"
|
||||||
|
selected={pickupDate}
|
||||||
|
onChange={setPickupDate}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="calendar" label="Return date & time" placeholder="Select Date" />
|
<DateTimePicker
|
||||||
|
label="Return date & time"
|
||||||
|
placeholder="Select Date"
|
||||||
|
selected={returnDate}
|
||||||
|
onChange={setReturnDate}
|
||||||
|
minDate={pickupDate || new Date()}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "oneway":
|
case "oneway":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField icon="location" label="From City" placeholder="Enter Pickup City" />
|
<LocationAutocomplete
|
||||||
|
label="From City"
|
||||||
|
placeholder="Enter Pickup City"
|
||||||
|
value={pickupLocation}
|
||||||
|
onChange={setPickupLocation}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="destination" label="To City" placeholder="Enter Drop Location" />
|
<LocationAutocomplete
|
||||||
|
label="To City"
|
||||||
|
placeholder="Enter Drop Location"
|
||||||
|
value={dropLocation}
|
||||||
|
icon="destination"
|
||||||
|
onChange={setDropLocation}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="calendar" label="Pickup date & time" placeholder="Select Date" />
|
<DateTimePicker
|
||||||
|
label="Pickup date & time"
|
||||||
|
placeholder="Select Date"
|
||||||
|
selected={pickupDate}
|
||||||
|
onChange={setPickupDate}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "local":
|
case "local":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField icon="location" label="From City" placeholder="Enter Pickup City" />
|
<LocationAutocomplete
|
||||||
|
label="From City"
|
||||||
|
placeholder="Enter Pickup City"
|
||||||
|
value={pickupLocation}
|
||||||
|
onChange={setPickupLocation}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="flex-1 min-w-[160px] px-4 py-3">
|
<div className="flex-1 min-w-[200px] px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-primary-600 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="14" x="2" y="7" rx="2" ry="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
|
<Navigation className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-badge-text font-medium leading-none mb-1">Select Package</p>
|
<p className="text-[11px] text-badge-text font-medium leading-none mb-1">Select Package</p>
|
||||||
<select
|
<select
|
||||||
|
|
@ -89,17 +152,38 @@ const HeroSection = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="calendar" label="Pickup date & time" placeholder="Select Date" />
|
<DateTimePicker
|
||||||
|
label="Pickup date & time"
|
||||||
|
placeholder="Select Date"
|
||||||
|
selected={pickupDate}
|
||||||
|
onChange={setPickupDate}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "airport":
|
case "airport":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField icon="location" label="From" placeholder="Enter Pickup Location" />
|
<LocationAutocomplete
|
||||||
|
label="From"
|
||||||
|
placeholder="Enter Pickup Location"
|
||||||
|
value={pickupLocation}
|
||||||
|
onChange={setPickupLocation}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="destination" label="Airport" placeholder="Enter Airport Name" />
|
<LocationAutocomplete
|
||||||
|
label="Airport"
|
||||||
|
placeholder="Enter Airport Name"
|
||||||
|
value={airportName}
|
||||||
|
icon="destination"
|
||||||
|
onChange={setAirportName}
|
||||||
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<FormField icon="calendar" label="Pickup date & time" placeholder="Select Date" />
|
<DateTimePicker
|
||||||
|
label="Pickup date & time"
|
||||||
|
placeholder="Select Date"
|
||||||
|
selected={pickupDate}
|
||||||
|
onChange={setPickupDate}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
|
@ -127,7 +211,7 @@ const HeroSection = () => {
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Booking widget */}
|
{/* Booking widget */}
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-[1100px] mx-auto">
|
||||||
{/* Trip tabs */}
|
{/* Trip tabs */}
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
<div className="inline-flex gap-1 p-1.5 bg-white/90 backdrop-blur-sm rounded-2xl shadow-lg border border-white/20">
|
<div className="inline-flex gap-1 p-1.5 bg-white/90 backdrop-blur-sm rounded-2xl shadow-lg border border-white/20">
|
||||||
|
|
@ -157,13 +241,16 @@ const HeroSection = () => {
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-xl p-2">
|
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-xl p-2">
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-0">
|
<div className="flex flex-col xl:flex-row items-stretch xl:items-center gap-1 sm:gap-0">
|
||||||
{getFormFields()}
|
{getFormFields()}
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA Button */}
|
||||||
<button className="flex-shrink-0 inline-flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold text-sm px-6 py-3.5 rounded-xl transition-all duration-200 shadow-md shadow-primary-600/20 hover:shadow-lg hover:shadow-primary-600/30 cursor-pointer mx-2 sm:mx-0 sm:ml-auto">
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="flex-shrink-0 inline-flex items-center justify-center gap-2 bg-primary-600 hover:bg-primary-700 text-white font-semibold text-sm px-6 py-4 rounded-xl transition-all duration-200 shadow-md shadow-primary-600/20 hover:shadow-lg hover:shadow-primary-600/30 cursor-pointer m-1 xl:ml-auto"
|
||||||
|
>
|
||||||
Start Booking Now
|
Start Booking Now
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
<ArrowRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,38 +262,8 @@ const HeroSection = () => {
|
||||||
|
|
||||||
/* --- Sub-components --- */
|
/* --- Sub-components --- */
|
||||||
|
|
||||||
const FormField = ({ icon, label, placeholder }) => {
|
|
||||||
const icons = {
|
|
||||||
location: (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-red-500 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
|
||||||
),
|
|
||||||
destination: (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-primary-600 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>
|
|
||||||
),
|
|
||||||
calendar: (
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4 text-dark-700 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex-1 min-w-[160px] px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{icons[icon]}
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] text-badge-text font-medium leading-none mb-1">{label}</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder={placeholder}
|
|
||||||
className="text-sm font-semibold text-dark-900 placeholder:text-gray-400 bg-transparent border-none outline-none w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const Divider = () => (
|
const Divider = () => (
|
||||||
<div className="hidden sm:block w-px h-10 bg-gray-200 self-center flex-shrink-0" />
|
<div className="hidden xl:block w-px h-10 bg-gray-200 self-center flex-shrink-0 mx-2" />
|
||||||
);
|
);
|
||||||
|
|
||||||
export default HeroSection;
|
export default HeroSection;
|
||||||
|
|
|
||||||
109
src/components/Hero/LocationAutocomplete.jsx
Normal file
109
src/components/Hero/LocationAutocomplete.jsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { MapPin, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
const LocationAutocomplete = ({ label, value, onChange, placeholder, icon = "location" }) => {
|
||||||
|
const [query, setQuery] = useState(value || "");
|
||||||
|
const [suggestions, setSuggestions] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showDropdown, setShowDropdown] = useState(false);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchSuggestions = async (searchText) => {
|
||||||
|
if (searchText.length < 3) {
|
||||||
|
setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`https://photon.komoot.io/api?q=${encodeURIComponent(searchText)}&limit=5`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const results = data.features.map(feature => {
|
||||||
|
const { name, city, state, country } = feature.properties;
|
||||||
|
const mainText = name || city;
|
||||||
|
const secondaryText = [city, state, country].filter(Boolean).join(", ");
|
||||||
|
return {
|
||||||
|
id: feature.geometry.coordinates.join(","),
|
||||||
|
mainText,
|
||||||
|
secondaryText,
|
||||||
|
description: `${mainText}${secondaryText ? `, ${secondaryText}` : ""}`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuggestions(results);
|
||||||
|
setShowDropdown(results.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching location suggestions:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedFetch = useRef(debounce(fetchSuggestions, 500)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query !== value) {
|
||||||
|
debouncedFetch(query);
|
||||||
|
}
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setShowDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (suggestion) => {
|
||||||
|
setQuery(suggestion.description);
|
||||||
|
onChange(suggestion.description);
|
||||||
|
setShowDropdown(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-[200px] px-4 py-3 relative" ref={dropdownRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className={`w-4 h-4 ${icon === 'location' ? 'text-red-500' : 'text-primary-600'} flex-shrink-0`} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-[11px] text-badge-text font-medium leading-none mb-1">{label}</p>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => query.length >= 3 && setShowDropdown(true)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="text-sm font-semibold text-dark-900 placeholder:text-gray-400 bg-transparent border-none outline-none w-full"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<Loader2 className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-gray-400 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown Suggestions */}
|
||||||
|
{showDropdown && suggestions.length > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-full mt-2 bg-white/95 backdrop-blur-md rounded-xl shadow-2xl border border-gray-100 z-50 overflow-hidden animate-fadeInUp">
|
||||||
|
{suggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleSelect(suggestion)}
|
||||||
|
className="w-full text-left px-5 py-3 hover:bg-gray-50 transition-colors flex flex-col border-b border-gray-50 last:border-none"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-bold text-dark-900">{suggestion.mainText}</span>
|
||||||
|
<span className="text-[11px] text-gray-500 truncate">{suggestion.secondaryText}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationAutocomplete;
|
||||||
|
|
@ -5,10 +5,10 @@ const Navbar = ({ onLoginClick, user }) => {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: "Home", href: "#hero" },
|
{ label: "Home", href: "/#hero" },
|
||||||
{ label: "Our Fleet", href: "#fleet" },
|
{ label: "Our Fleet", href: "/#fleet" },
|
||||||
{ label: "About", href: "#about" },
|
{ label: "About", href: "/#about" },
|
||||||
{ label: "Contact", href: "#contact" },
|
{ label: "Contact", href: "/#contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -53,7 +53,7 @@ const Navbar = ({ onLoginClick, user }) => {
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16 md:h-[72px]">
|
<div className="flex items-center justify-between h-16 md:h-[72px]">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<a href="#" className="flex-shrink-0 hover:opacity-90 transition-opacity">
|
<a href="/" className="flex-shrink-0 hover:opacity-90 transition-opacity">
|
||||||
<Logo />
|
<Logo />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
@ -73,7 +73,7 @@ const Navbar = ({ onLoginClick, user }) => {
|
||||||
{/* Book Now CTA */}
|
{/* Book Now CTA */}
|
||||||
<div className="hidden md:flex items-center">
|
<div className="hidden md:flex items-center">
|
||||||
<a
|
<a
|
||||||
href="#hero"
|
href="/#hero"
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 border-2 border-primary-600 text-primary-600 font-semibold text-sm rounded-xl hover:bg-primary-600 hover:text-white transition-all duration-200"
|
className="inline-flex items-center gap-2 px-5 py-2.5 border-2 border-primary-600 text-primary-600 font-semibold text-sm rounded-xl hover:bg-primary-600 hover:text-white transition-all duration-200"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2" /><line x1="16" x2="16" y1="2" y2="6" /><line x1="8" x2="8" y1="2" y2="6" /><line x1="3" x2="21" y1="10" y2="10" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2" /><line x1="16" x2="16" y1="2" y2="6" /><line x1="8" x2="8" y1="2" y2="6" /><line x1="3" x2="21" y1="10" y2="10" /></svg>
|
||||||
|
|
@ -112,7 +112,7 @@ const Navbar = ({ onLoginClick, user }) => {
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a
|
<a
|
||||||
href="#hero"
|
href="/#hero"
|
||||||
className="block mx-4 mt-3 px-4 py-2.5 text-center bg-primary-600 text-white font-semibold text-sm rounded-xl"
|
className="block mx-4 mt-3 px-4 py-2.5 text-center bg-primary-600 text-white font-semibold text-sm rounded-xl"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
31
src/pages/HomePage.jsx
Normal file
31
src/pages/HomePage.jsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from "react";
|
||||||
|
import HeroSection from "../components/Hero/HeroSection";
|
||||||
|
import CabsSection from "../components/Cabs/CabsSection";
|
||||||
|
import ServicesSection from "../components/Services/ServicesSection";
|
||||||
|
import PromoBanners from "../components/Promotions/PromoBanners";
|
||||||
|
import ReviewsSection from "../components/Reviews/ReviewsSection";
|
||||||
|
import WhyChooseUs from "../components/Highlights/WhyChooseUs";
|
||||||
|
import NewsSection from "../components/News/NewsSection";
|
||||||
|
import Navbar from "../components/Navbar/Navbar";
|
||||||
|
import Footer from "../components/Footer/Footer";
|
||||||
|
|
||||||
|
const HomePage = ({ onLoginClick, user }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar
|
||||||
|
user={user}
|
||||||
|
onLoginClick={onLoginClick}
|
||||||
|
/>
|
||||||
|
<HeroSection />
|
||||||
|
<CabsSection />
|
||||||
|
<ServicesSection />
|
||||||
|
<PromoBanners />
|
||||||
|
<ReviewsSection />
|
||||||
|
<WhyChooseUs />
|
||||||
|
<NewsSection />
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
478
src/pages/SearchPage.jsx
Normal file
478
src/pages/SearchPage.jsx
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
import React, { useMemo, useState, useEffect } from "react";
|
||||||
|
import { useSearchParams, Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Calendar, MapPin, Users, Luggage, ShieldCheck,
|
||||||
|
ArrowLeft, Info, CheckCircle2, Loader2, X,
|
||||||
|
ChevronRight, ArrowRight, User, Phone
|
||||||
|
} from "lucide-react";
|
||||||
|
import Navbar from "../components/Navbar/Navbar";
|
||||||
|
import Footer from "../components/Footer/Footer";
|
||||||
|
import { api } from "../lib/api";
|
||||||
|
|
||||||
|
// Import local assets
|
||||||
|
import innovaCrystaImg from "../assets/vehicles/innova_crysta.png";
|
||||||
|
import innovaImg from "../assets/vehicles/innova.png";
|
||||||
|
import fortunerImg from "../assets/vehicles/fortuner.png";
|
||||||
|
import dzireImg from "../assets/vehicles/dzire.png";
|
||||||
|
import etiosImg from "../assets/vehicles/etios.png";
|
||||||
|
import audiA6Img from "../assets/vehicles/audi_a6.png";
|
||||||
|
import audiQ7Img from "../assets/vehicles/audi_q7.png";
|
||||||
|
import bmw5Img from "../assets/vehicles/bmw_5_series.png";
|
||||||
|
import bmw7Img from "../assets/vehicles/bmw_7_series.png";
|
||||||
|
import camryImg from "../assets/vehicles/camry.png";
|
||||||
|
import corollaImg from "../assets/vehicles/corolla_altis.png";
|
||||||
|
import mercEImg from "../assets/vehicles/mercedes_e_class.png";
|
||||||
|
import mercSImg from "../assets/vehicles/mercedes_s_class.png";
|
||||||
|
import commuterImg from "../assets/vehicles/toyota_commuter.png";
|
||||||
|
|
||||||
|
const carImages = {
|
||||||
|
innova_crysta: innovaCrystaImg,
|
||||||
|
innova: innovaImg,
|
||||||
|
fortuner: fortunerImg,
|
||||||
|
dzire: dzireImg,
|
||||||
|
etios: etiosImg,
|
||||||
|
audi_a6: audiA6Img,
|
||||||
|
audi_q7: audiQ7Img,
|
||||||
|
bmw_5_series: bmw5Img,
|
||||||
|
bmw_7_series: bmw7Img,
|
||||||
|
camry: camryImg,
|
||||||
|
corolla_altis: corollaImg,
|
||||||
|
mercedes_e_class: mercEImg,
|
||||||
|
mercedes_s_class: mercSImg,
|
||||||
|
toyota_commuter: commuterImg,
|
||||||
|
ertiga: dzireImg, // Fallback
|
||||||
|
};
|
||||||
|
|
||||||
|
const LeadCaptureModal = ({ isOpen, onSubmit, searchData }) => {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [mobile, setMobile] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.post("/api/leads", {
|
||||||
|
name,
|
||||||
|
mobile_number: mobile,
|
||||||
|
tripType: searchData.tripType,
|
||||||
|
pickupLocation: searchData.pickup,
|
||||||
|
dropLocation: searchData.drop
|
||||||
|
});
|
||||||
|
onSubmit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Lead Error:", err);
|
||||||
|
// Still allow them to see results even if lead fails, or show error
|
||||||
|
onSubmit();
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-dark-900/40 backdrop-blur-md animate-fadeIn">
|
||||||
|
<div className="bg-white rounded-3xl p-8 max-w-md w-full shadow-2xl animate-scaleIn">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 bg-primary-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||||
|
<User className="w-8 h-8 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-black text-dark-900 mb-2">Check Special Prices</h2>
|
||||||
|
<p className="text-gray-500 text-sm">Enter your details to view exclusive discounted rates for your trip.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
placeholder="Your Full Name"
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl outline-none focus:border-primary-600 focus:bg-white transition-all font-semibold"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="tel"
|
||||||
|
placeholder="Mobile Number"
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-gray-50 border border-gray-100 rounded-2xl outline-none focus:border-primary-600 focus:bg-white transition-all font-semibold"
|
||||||
|
value={mobile}
|
||||||
|
onChange={(e) => setMobile(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-primary-600 hover:bg-primary-700 text-white font-black py-4 rounded-2xl transition-all shadow-lg shadow-primary-600/20 flex items-center justify-center gap-2 group"
|
||||||
|
>
|
||||||
|
{isSubmitting ? <Loader2 className="w-5 h-5 animate-spin" /> : (
|
||||||
|
<>
|
||||||
|
View Deals
|
||||||
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-[10px] text-gray-400 mt-6 font-bold uppercase tracking-widest">
|
||||||
|
No spam. Only trip updates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SearchPage = ({ onLoginClick, user }) => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [isBooking, setIsBooking] = useState(false);
|
||||||
|
const [bookingSuccess, setBookingSuccess] = useState(null);
|
||||||
|
const [showLeadModal, setShowLeadModal] = useState(!user); // Don't show if user logged in
|
||||||
|
const [cars, setCars] = useState([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Extract search details
|
||||||
|
const tripType = searchParams.get("tripType") || "round";
|
||||||
|
const pickupLocation = searchParams.get("pickup") || "Not Specified";
|
||||||
|
const dropLocation = searchParams.get("drop") || "";
|
||||||
|
const pickupDateStr = searchParams.get("pickupDate");
|
||||||
|
const returnDateStr = searchParams.get("returnDate");
|
||||||
|
const packageType = searchParams.get("package") || "";
|
||||||
|
|
||||||
|
const pickupDate = pickupDateStr ? new Date(pickupDateStr) : new Date();
|
||||||
|
const returnDate = returnDateStr ? new Date(returnDateStr) : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPricing();
|
||||||
|
}, [tripType, pickupDateStr, returnDateStr, packageType]);
|
||||||
|
|
||||||
|
const fetchPricing = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post("/api/pricing/calculate", {
|
||||||
|
tripType,
|
||||||
|
pickupDate: pickupDateStr,
|
||||||
|
returnDate: returnDateStr,
|
||||||
|
packageType
|
||||||
|
});
|
||||||
|
setCars(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Pricing Fetch Error:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookNow = async (car) => {
|
||||||
|
// If not logged in, ask for lead first (Leads-first flow)
|
||||||
|
if (!user && showLeadModal) {
|
||||||
|
// Modal is already handling this or will appear
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsBooking(true);
|
||||||
|
try {
|
||||||
|
const response = await api.post("/api/bookings", {
|
||||||
|
tripType,
|
||||||
|
pickupLocation,
|
||||||
|
dropLocation,
|
||||||
|
pickupDateTime: pickupDate.toISOString(),
|
||||||
|
returnDateTime: returnDate ? returnDate.toISOString() : null,
|
||||||
|
packageType,
|
||||||
|
carName: car.name,
|
||||||
|
totalAmount: car.calc.grandTotal
|
||||||
|
});
|
||||||
|
|
||||||
|
setBookingSuccess(response.bookingId);
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to create booking: " + err.message);
|
||||||
|
} finally {
|
||||||
|
setIsBooking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||||
|
<Navbar onLoginClick={onLoginClick} user={user} />
|
||||||
|
|
||||||
|
{/* Lead Capture Modal */}
|
||||||
|
<LeadCaptureModal
|
||||||
|
isOpen={showLeadModal}
|
||||||
|
onSubmit={() => setShowLeadModal(false)}
|
||||||
|
searchData={{ tripType, pickup: pickupLocation, drop: dropLocation }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 pt-28 flex-grow">
|
||||||
|
|
||||||
|
{/* Search Summary Header */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-8 flex flex-col md:flex-row md:items-center justify-between gap-6 animate-fadeIn">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Link to="/" className="flex items-center gap-2 text-primary-600 font-bold text-sm hover:translate-x-[-4px] transition-transform w-fit uppercase tracking-wider">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Modify Search
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap items-center gap-y-3 gap-x-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-primary-50 p-2 rounded-lg text-primary-600 font-bold">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Pickup</p>
|
||||||
|
<p className="text-sm font-bold text-dark-900">{pickupLocation}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{dropLocation && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-red-50 p-2 rounded-lg text-red-500 font-bold">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Drop Location</p>
|
||||||
|
<p className="text-sm font-bold text-dark-900">{dropLocation}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="bg-orange-50 p-2 rounded-lg text-orange-600 font-bold">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-gray-400 font-bold uppercase tracking-widest">Schedule</p>
|
||||||
|
<p className="text-sm font-bold text-dark-900">
|
||||||
|
{pickupDate.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}
|
||||||
|
{returnDate && ` - ${returnDate.toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' })}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pb-20">
|
||||||
|
|
||||||
|
{/* Main Listings */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-xl font-black text-dark-900 uppercase tracking-tight">Available Fleets</h2>
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest bg-white px-3 py-1.5 rounded-full border border-gray-100 shadow-sm">
|
||||||
|
Found {cars.length} Options
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-white rounded-3xl p-20 flex flex-col items-center justify-center shadow-sm border border-gray-100">
|
||||||
|
<Loader2 className="w-10 h-10 text-primary-600 animate-spin mb-4" />
|
||||||
|
<p className="text-gray-500 font-bold animate-pulse uppercase tracking-widest text-xs">Finding Best Fares...</p>
|
||||||
|
</div>
|
||||||
|
) : cars.map((car) => (
|
||||||
|
<div key={car.id} className="bg-white rounded-3xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-2xl hover:shadow-primary-600/5 transition-all duration-500 group">
|
||||||
|
<div className="flex flex-col xl:flex-row p-6 gap-8">
|
||||||
|
{/* Car Image Wrapper */}
|
||||||
|
<div className="xl:w-[280px] bg-gray-50 rounded-2xl flex items-center justify-center p-6 relative overflow-hidden group-hover:bg-primary-50/30 transition-colors">
|
||||||
|
<img src={carImages[car.image]} alt={car.name} className="w-full h-auto object-contain transform group-hover:scale-110 transition-transform duration-700 drop-shadow-xl" />
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
{car.badge && (
|
||||||
|
<div className="absolute top-4 left-4 bg-primary-600 text-white text-[9px] font-black px-3 py-1.5 rounded-full shadow-lg uppercase tracking-widest animate-bounceIn">
|
||||||
|
{car.badge}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm px-2.5 py-1.5 rounded-xl border border-white flex items-center gap-1.5 shadow-sm">
|
||||||
|
<span className="text-yellow-500 font-black tracking-tighter">★★★★★</span>
|
||||||
|
<span className="text-[10px] font-black text-dark-900">Top Rated</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Car Details */}
|
||||||
|
<div className="flex-1 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-black text-dark-900 group-hover:text-primary-600 transition-colors">{car.name}</h3>
|
||||||
|
<div className="flex items-center gap-4 mt-1.5">
|
||||||
|
<span className="text-[10px] font-black text-primary-600 bg-primary-50 px-2.5 py-1 rounded-lg uppercase tracking-widest">{car.type}</span>
|
||||||
|
<div className="flex items-center gap-3 text-gray-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-[11px] font-bold">{car.seats}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Luggage className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-[11px] font-bold">{car.luggage}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-3xl font-black text-dark-900 leading-none">₹{car.calc.grandTotal}</p>
|
||||||
|
<p className="text-[9px] font-black text-gray-400 mt-2 uppercase tracking-widest border-t border-gray-50 pt-2 inline-block">Final Amount</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transparency Math Block */}
|
||||||
|
<div className="bg-primary-50/50 rounded-2xl p-4 mb-6 border border-primary-100/30">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[10px] font-black text-primary-900 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
<Info className="w-3.5 h-3.5" />
|
||||||
|
Transparent Breakdown
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-black text-primary-600">{car.calc.math}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-[10px] font-bold text-gray-500 uppercase tracking-widest font-mono">20% Booking Advance</span>
|
||||||
|
<span className="text-sm font-black text-primary-600">Pay ₹{car.calc.advancePayment} to Confirm</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inclusions / Exclusions */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pb-6 border-b border-gray-50">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-green-600 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> Included
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{car.inclusions.map((inc, i) => (
|
||||||
|
<li key={i} className="text-[11px] font-bold text-gray-500 flex items-center gap-1.5">
|
||||||
|
<div className="w-1 h-1 bg-green-500 rounded-full" /> {inc}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] font-black text-red-500 uppercase tracking-widest mb-2 flex items-center gap-1.5">
|
||||||
|
<X className="w-3 h-3" /> Excluded
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{car.exclusions.map((exc, i) => (
|
||||||
|
<li key={i} className="text-[11px] font-bold text-gray-400 flex items-center gap-1.5 line-through decoration-red-200">
|
||||||
|
{exc}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">Available Distance</span>
|
||||||
|
<span className="text-sm font-black text-dark-900">{car.calc.totalKmIncluded} Km Included</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleBookNow(car)}
|
||||||
|
disabled={isBooking}
|
||||||
|
className="bg-dark-900 hover:bg-primary-600 text-white font-black text-xs px-8 py-4 rounded-2xl transition-all duration-300 shadow-xl shadow-dark-900/10 hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 group"
|
||||||
|
>
|
||||||
|
{isBooking ? <Loader2 className="w-4 h-4 animate-spin text-white" /> : (
|
||||||
|
<>
|
||||||
|
Book for ₹{car.calc.advancePayment} <ChevronRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Summary Sidebar */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="sticky top-28 space-y-6">
|
||||||
|
<div className="bg-white rounded-3xl shadow-sm border border-gray-100 p-8 overflow-hidden relative group">
|
||||||
|
{/* Decorative element */}
|
||||||
|
<div className="absolute -top-10 -right-10 w-32 h-32 bg-primary-50 rounded-full opacity-50 blur-3xl group-hover:bg-primary-100 transition-colors duration-500" />
|
||||||
|
|
||||||
|
<h2 className="text-xl font-black text-dark-900 mb-8 flex items-center gap-3 relative z-10">
|
||||||
|
Terms & Policy
|
||||||
|
< ShieldCheck className="w-5 h-5 text-green-500" />
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-5 relative z-10">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1 w-5 h-5 bg-green-50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-dark-900 mb-0.5">Toll & State Permits</p>
|
||||||
|
<p className="text-[11px] font-bold text-gray-500 leading-relaxed">To be paid by traveller as per actuals during the trip.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1 w-5 h-5 bg-primary-50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Info className="w-3 h-3 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-dark-900 mb-0.5">Extra Kilimoteres</p>
|
||||||
|
<p className="text-[11px] font-bold text-gray-500 leading-relaxed">Charged at standard base rates mentioned on the car cards.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-1 w-5 h-5 bg-orange-50 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Calendar className="w-3 h-3 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-black text-dark-900 mb-0.5">Night Charge</p>
|
||||||
|
<p className="text-[11px] font-bold text-gray-500 leading-relaxed">Driver night allowance applicable after 10:00 PM.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 p-5 bg-gray-50 rounded-2xl border border-gray-100 text-center">
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-1.5">Need Custom Help?</p>
|
||||||
|
<a href="tel:+917975942520" className="text-sm font-black text-dark-900 hover:text-primary-600 transition-colors">+91 7975942520</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-dark-900 rounded-3xl p-8 text-white relative overflow-hidden shadow-2xl">
|
||||||
|
<div className="absolute top-0 right-0 w-32 h-32 bg-primary-600/20 rounded-full blur-3xl" />
|
||||||
|
<h3 className="text-lg font-black mb-4 flex items-center gap-2">
|
||||||
|
<ShieldCheck className="w-5 h-5 text-primary-400" />
|
||||||
|
Refund Guarantee
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs font-bold text-gray-400 leading-relaxed">
|
||||||
|
Cancel up to 24 hours in advance and receive a full refund of your 20% booking deposit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Success Modal */}
|
||||||
|
{bookingSuccess && (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-dark-900/60 backdrop-blur-sm animate-fadeIn">
|
||||||
|
<div className="bg-white rounded-[40px] p-10 max-w-sm w-full text-center shadow-3xl animate-scaleIn border border-white">
|
||||||
|
<div className="w-24 h-24 bg-green-50 rounded-[35px] flex items-center justify-center mx-auto mb-8 shadow-inner">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-black text-dark-900 mb-3 tracking-tighter">Trip Confirmed!</h2>
|
||||||
|
<p className="text-gray-500 text-sm mb-10 leading-relaxed px-2 font-bold">
|
||||||
|
We've received your booking. Our professional driver will reach out to you shortly.
|
||||||
|
</p>
|
||||||
|
<div className="bg-gray-50/80 rounded-[28px] p-6 mb-10 border border-gray-100">
|
||||||
|
<p className="text-[10px] font-black text-gray-400 uppercase tracking-widest mb-2">Booking Transaction ID</p>
|
||||||
|
<p className="text-2xl font-black text-dark-900 tracking-tighter">#RR-{bookingSuccess}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setBookingSuccess(null)}
|
||||||
|
className="w-full bg-dark-900 hover:bg-black text-white font-black py-5 rounded-2xl transition-all shadow-xl shadow-dark-900/20 active:scale-95"
|
||||||
|
>
|
||||||
|
Done & Back to Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchPage;
|
||||||
|
|
@ -7,9 +7,8 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://192.168.1.27:8000',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true
|
||||||
rewrite: (path) => path.replace(/^\/api/, '')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue