init
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
DB_TYPE=mysql
|
||||||
|
DB_HOST=zero-db.naiv.dev
|
||||||
|
DB_PORT=23306
|
||||||
|
DB_NAME=db_a41dfa3bd96242409d2a3565939d2be4
|
||||||
|
DB_USERNAME=uname_50a1caca5d7441ad9f6fce806a
|
||||||
|
DB_PASSWORD=pwd_6c60547f55af41f58b0f2b2a54054a4c
|
||||||
|
DATABASE_URL="mysql://uname_50a1caca5d7441ad9f6fce806a:pwd_6c60547f55af41f58b0f2b2a54054a4c@zero-db.naiv.dev:23306/db_a41dfa3bd96242409d2a3565939d2be4"
|
||||||
@@ -34,3 +34,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# ---- Stage 1: Dependencies ----
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---- Stage 2: Build ----
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Generate Prisma Client (uses prisma.config.ts which is in the build context)
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
# Build Next.js
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Stage 3: Production ----
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Copy built app & dependencies
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||||
|
COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
||||||
|
|
||||||
|
# Entrypoint script for migrations + seeding + start
|
||||||
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: travel-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: rootpassword
|
||||||
|
MYSQL_DATABASE: travel_antarkota
|
||||||
|
MYSQL_USER: travel_user
|
||||||
|
MYSQL_PASSWORD: travel_password
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-prootpassword"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: travel-app
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
# Database
|
||||||
|
DB_TYPE: mysql
|
||||||
|
DB_HOST: mysql
|
||||||
|
DB_PORT: 3306
|
||||||
|
DB_NAME: travel_antarkota
|
||||||
|
DB_USERNAME: travel_user
|
||||||
|
DB_PASSWORD: travel_password
|
||||||
|
DATABASE_URL: "mysql://travel_user:travel_password@mysql:3306/travel_antarkota"
|
||||||
|
|
||||||
|
# Security
|
||||||
|
JWT_SECRET: "change-this-to-a-strong-random-secret-key-in-production"
|
||||||
|
|
||||||
|
# Initial Admin Credentials (used by seed script)
|
||||||
|
ADMIN_EMAIL: admin@travel.com
|
||||||
|
ADMIN_PASSWORD: admin123
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "⏳ Waiting for MySQL to be ready..."
|
||||||
|
|
||||||
|
MAX_RETRIES=30
|
||||||
|
RETRY=0
|
||||||
|
until node -e "
|
||||||
|
const net = require('net');
|
||||||
|
const client = new net.Socket();
|
||||||
|
client.setTimeout(2000);
|
||||||
|
client.connect(Number(process.env.DB_PORT), process.env.DB_HOST, () => {
|
||||||
|
client.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
client.on('error', () => { client.destroy(); process.exit(1); });
|
||||||
|
client.on('timeout', () => { client.destroy(); process.exit(1); });
|
||||||
|
" 2>/dev/null; do
|
||||||
|
RETRY=$((RETRY + 1))
|
||||||
|
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||||
|
echo "❌ MySQL did not become ready in time. Exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " MySQL not ready yet, retrying in 3s... ($RETRY/$MAX_RETRIES)"
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
# Extra grace period for MySQL to finish internal auth setup
|
||||||
|
sleep 2
|
||||||
|
echo "✅ MySQL is ready!"
|
||||||
|
|
||||||
|
# Run Prisma schema push (Prisma v7: no --skip-generate flag)
|
||||||
|
echo "🔄 Pushing Prisma schema to database..."
|
||||||
|
npx prisma db push
|
||||||
|
|
||||||
|
# Seed only if no admin user exists yet
|
||||||
|
echo "🌱 Checking if seed is needed..."
|
||||||
|
ADMIN_EXISTS=$(node -e "
|
||||||
|
const mariadb = require('mariadb');
|
||||||
|
const pool = mariadb.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USERNAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
connectionLimit: 1
|
||||||
|
});
|
||||||
|
pool.getConnection()
|
||||||
|
.then(async conn => {
|
||||||
|
const rows = await conn.query('SELECT COUNT(*) as cnt FROM User WHERE role = ?', ['ADMIN']);
|
||||||
|
conn.release();
|
||||||
|
await pool.end();
|
||||||
|
console.log(rows[0].cnt > 0 ? 'yes' : 'no');
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
try { await pool.end(); } catch(e) {}
|
||||||
|
console.log('no');
|
||||||
|
});
|
||||||
|
" 2>/dev/null)
|
||||||
|
|
||||||
|
echo " Admin exists check: $ADMIN_EXISTS"
|
||||||
|
|
||||||
|
if [ "$ADMIN_EXISTS" = "yes" ]; then
|
||||||
|
echo "✅ Admin user already exists, skipping seed."
|
||||||
|
else
|
||||||
|
echo "🌱 Seeding database with initial data..."
|
||||||
|
node prisma/seed.js
|
||||||
|
echo "✅ Seed complete!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Starting Next.js server..."
|
||||||
|
exec npm start
|
||||||
+8
-1
@@ -1,4 +1,11 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {};
|
const nextConfig = {
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+1171
-17
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -9,16 +9,26 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@prisma/adapter-mariadb": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"mariadb": "^3.5.3",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"next": "14.2.35"
|
"react-icons": "^5.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "14.2.35"
|
"eslint-config-next": "14.2.35",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
dotenv.config({ override: true });
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// This is your Prisma schema file,
|
||||||
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String
|
||||||
|
phone String
|
||||||
|
role String @default("USER") // USER or ADMIN
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
bookings Booking[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Route {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
departureCity String
|
||||||
|
arrivalCity String
|
||||||
|
durationMinutes Int
|
||||||
|
basePrice Decimal @db.Decimal(10, 2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
schedules Schedule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Schedule {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
routeId Int
|
||||||
|
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||||
|
departureTime DateTime
|
||||||
|
arrivalTime DateTime
|
||||||
|
vehicleType String // "Toyota HiAce" or "Executive Bus"
|
||||||
|
capacity Int
|
||||||
|
price Decimal @db.Decimal(10, 2)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
bookings Booking[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Booking {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
bookingCode String @unique // e.g. "TRV-XXXXXX"
|
||||||
|
userId Int?
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
scheduleId Int
|
||||||
|
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||||
|
passengerName String
|
||||||
|
passengerEmail String
|
||||||
|
passengerPhone String
|
||||||
|
totalPrice Decimal @db.Decimal(10, 2)
|
||||||
|
status String @default("PENDING") // PENDING, PAID, CANCELLED
|
||||||
|
paymentMethod String?
|
||||||
|
paymentTime DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
seats BookingSeat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model BookingSeat {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
bookingId Int
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||||
|
scheduleId Int
|
||||||
|
seatNumber String // e.g. "1", "2A", etc.
|
||||||
|
|
||||||
|
@@unique([scheduleId, seatNumber])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemSetting {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
key String @unique
|
||||||
|
value String @db.Text
|
||||||
|
}
|
||||||
|
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const { PrismaMariaDb } = require('@prisma/adapter-mariadb');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
|
||||||
|
dotenv.config({ override: true });
|
||||||
|
|
||||||
|
const adapter = new PrismaMariaDb({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USERNAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
connectionLimit: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
// 1. Clear existing data
|
||||||
|
await prisma.bookingSeat.deleteMany({});
|
||||||
|
await prisma.booking.deleteMany({});
|
||||||
|
await prisma.schedule.deleteMany({});
|
||||||
|
await prisma.route.deleteMany({});
|
||||||
|
await prisma.user.deleteMany({});
|
||||||
|
|
||||||
|
console.log('Cleared existing data.');
|
||||||
|
|
||||||
|
// 2. Create Users
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@travel.com';
|
||||||
|
const adminPass = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
|
const hashedPasswordAdmin = await bcrypt.hash(adminPass, 10);
|
||||||
|
const admin = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: adminEmail,
|
||||||
|
password: hashedPasswordAdmin,
|
||||||
|
name: 'Super Admin',
|
||||||
|
phone: '081234567890',
|
||||||
|
role: 'ADMIN',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hashedPasswordUser = await bcrypt.hash('user123', 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: 'user@travel.com',
|
||||||
|
password: hashedPasswordUser,
|
||||||
|
name: 'John Doe',
|
||||||
|
phone: '089876543210',
|
||||||
|
role: 'USER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created users:', { admin: admin.email, user: user.email });
|
||||||
|
|
||||||
|
// 3. Create Routes
|
||||||
|
const routesData = [
|
||||||
|
{ departureCity: 'Jakarta', arrivalCity: 'Bandung', durationMinutes: 180, basePrice: 150000 },
|
||||||
|
{ departureCity: 'Bandung', arrivalCity: 'Jakarta', durationMinutes: 180, basePrice: 150000 },
|
||||||
|
{ departureCity: 'Jakarta', arrivalCity: 'Bogor', durationMinutes: 90, basePrice: 75000 },
|
||||||
|
{ departureCity: 'Bogor', arrivalCity: 'Jakarta', durationMinutes: 90, basePrice: 75000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const routes = [];
|
||||||
|
for (const r of routesData) {
|
||||||
|
const route = await prisma.route.create({ data: r });
|
||||||
|
routes.push(route);
|
||||||
|
}
|
||||||
|
console.log(`Created ${routes.length} routes.`);
|
||||||
|
|
||||||
|
// 4. Create Schedules for the next 3 days
|
||||||
|
const today = new Date();
|
||||||
|
let schedulesCreated = 0;
|
||||||
|
|
||||||
|
for (let dayOffset = 0; dayOffset < 3; dayOffset++) {
|
||||||
|
const targetDate = new Date(today);
|
||||||
|
targetDate.setDate(today.getDate() + dayOffset);
|
||||||
|
const dateStr = targetDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
// Create some schedules per route
|
||||||
|
let hours = [];
|
||||||
|
if (route.departureCity === 'Jakarta' && route.arrivalCity === 'Bandung') {
|
||||||
|
hours = [7, 10, 13, 16, 19];
|
||||||
|
} else if (route.departureCity === 'Bandung' && route.arrivalCity === 'Jakarta') {
|
||||||
|
hours = [7, 10, 13, 16, 19];
|
||||||
|
} else {
|
||||||
|
hours = [8, 12, 16];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < hours.length; i++) {
|
||||||
|
const hour = hours[i];
|
||||||
|
const departureTime = new Date(`${dateStr}T${String(hour).padStart(2, '0')}:00:00.000Z`);
|
||||||
|
const arrivalTime = new Date(departureTime.getTime() + route.durationMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
// Alternate vehicle types
|
||||||
|
const isHiace = i % 2 === 0;
|
||||||
|
const vehicleType = isHiace ? 'Toyota HiAce' : 'Executive Bus';
|
||||||
|
const capacity = isHiace ? 10 : 30;
|
||||||
|
const price = isHiace ? Number(route.basePrice) : Number(route.basePrice) - 20000;
|
||||||
|
|
||||||
|
await prisma.schedule.create({
|
||||||
|
data: {
|
||||||
|
routeId: route.id,
|
||||||
|
departureTime,
|
||||||
|
arrivalTime,
|
||||||
|
vehicleType,
|
||||||
|
capacity,
|
||||||
|
price,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
schedulesCreated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Seeded ${schedulesCreated} schedules successfully.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,223 @@
|
|||||||
|
.adminLayout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loadingContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 20px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 260px;
|
||||||
|
background: var(--bg-sidebar, color-mix(in srgb, var(--primary) 7%, rgba(4, 5, 10, 0.95)));
|
||||||
|
border-right: 1px solid var(--border-light);
|
||||||
|
padding: 30px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
height: 100vh;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarBrand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandIcon {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarBrand h3 {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminBadge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
color: var(--text-white);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarNav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
background: var(--bg-navlink-hover, rgba(255, 255, 255, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink.active {
|
||||||
|
color: var(--text-white);
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navIcon {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarFooter {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminAvatar {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-white);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminName {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminEmail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn:hover {
|
||||||
|
background: var(--accent-rose);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
margin-left: 260px;
|
||||||
|
flex-grow: 1;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topHeader {
|
||||||
|
height: 70px;
|
||||||
|
background: var(--bg-topheader, color-mix(in srgb, var(--primary) 4%, rgba(4, 5, 10, 0.4)));
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding: 0 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topHeader h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dateDisplay {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageContent {
|
||||||
|
padding: 40px;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 80px;
|
||||||
|
padding: 20px 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarBrand h3,
|
||||||
|
.adminBadge,
|
||||||
|
.navLink span:not(.navIcon),
|
||||||
|
.adminInfo,
|
||||||
|
.logoutBtn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
margin-left: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterBar {
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.filterBar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeText {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsBadge {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approveBtn,
|
||||||
|
.cancelBtn {
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approveBtn {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.approveBtn:hover {
|
||||||
|
background: var(--accent-emerald);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancelBtn:hover {
|
||||||
|
background: var(--accent-rose);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaClock } from 'react-icons/fa';
|
||||||
|
import styles from './bookings.module.css';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: number;
|
||||||
|
bookingCode: string;
|
||||||
|
passengerName: string;
|
||||||
|
passengerEmail: string;
|
||||||
|
passengerPhone: string;
|
||||||
|
totalPrice: number;
|
||||||
|
status: string; // PENDING, PAID, CANCELLED
|
||||||
|
paymentMethod: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
seats: { seatNumber: string }[];
|
||||||
|
schedule: {
|
||||||
|
departureTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
route: {
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminBookings() {
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [filteredBookings, setFilteredBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL'); // ALL, PENDING, PAID, CANCELLED
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBookings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchBookings() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/bookings');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal mengambil log pemesanan tiket.');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setBookings(data.bookings);
|
||||||
|
setFilteredBookings(data.bookings);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Terjadi kesalahan sistem.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and search
|
||||||
|
useEffect(() => {
|
||||||
|
let result = [...bookings];
|
||||||
|
|
||||||
|
if (statusFilter !== 'ALL') {
|
||||||
|
result = result.filter(b => b.status === statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
result = result.filter(b =>
|
||||||
|
b.bookingCode.toLowerCase().includes(term) ||
|
||||||
|
b.passengerName.toLowerCase().includes(term) ||
|
||||||
|
b.passengerPhone.includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredBookings(result);
|
||||||
|
}, [bookings, statusFilter, searchTerm]);
|
||||||
|
|
||||||
|
const handleUpdateStatus = async (id: number, status: 'PAID' | 'CANCELLED') => {
|
||||||
|
const actionText = status === 'PAID' ? 'menyetujui pelunasan' : 'membatalkan';
|
||||||
|
if (!confirm(`Apakah Anda yakin ingin ${actionText} pemesanan ini?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/bookings/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Gagal merubah status.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setBookings(bookings.map(b => b.id === id ? { ...b, status } : b));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Gagal mengubah status.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat daftar tiket...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Search & Filter Top bar */}
|
||||||
|
<div className={`${styles.filterBar} glass-panel`}>
|
||||||
|
<div className="form-group" style={{ margin: 0, flexGrow: 1 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari berdasarkan kode booking, nama penumpang, no. telepon..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ margin: 0, minWidth: '180px' }}>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="ALL">Semua Status</option>
|
||||||
|
<option value="PENDING">Menunggu Bayar (Pending)</option>
|
||||||
|
<option value="PAID">Lunas (Paid)</option>
|
||||||
|
<option value="CANCELLED">Dibatalkan (Cancelled)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
{/* Bookings Table */}
|
||||||
|
<div className="glass-panel" style={{ padding: '24px', overflowX: 'auto' }}>
|
||||||
|
{filteredBookings.length === 0 ? (
|
||||||
|
<p className={styles.emptyText}>Tidak ada pemesanan tiket yang cocok.</p>
|
||||||
|
) : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kode</th>
|
||||||
|
<th>Penumpang & Kontak</th>
|
||||||
|
<th>Jadwal Perjalanan</th>
|
||||||
|
<th>Kursi</th>
|
||||||
|
<th>Total Tarif</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Metode</th>
|
||||||
|
<th>Aksi Admin</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredBookings.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>
|
||||||
|
<strong className={styles.codeText}>{b.bookingCode}</strong>
|
||||||
|
<span style={{ display: 'block', fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||||||
|
{formatDate(b.createdAt)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: 'var(--text-white)' }}>{b.passengerName}</p>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{b.passengerEmail}</p>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)' }}>WA: {b.passengerPhone}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'var(--text-white)' }}>{b.schedule.route.departureCity} → {b.schedule.route.arrivalCity}</p>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{b.schedule.vehicleType}</p>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '2px' }}>
|
||||||
|
<FaClock style={{ marginRight: '4px' }} /> {new Date(b.schedule.departureTime).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.seatsBadge}>
|
||||||
|
{b.seats.map(s => s.seatNumber).join(', ')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-white)' }}>{formatCurrency(b.totalPrice)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${b.status === 'PAID' ? 'badge-paid' : b.status === 'CANCELLED' ? 'badge-cancelled' : 'badge-pending'}`}>
|
||||||
|
{b.status === 'PAID' ? 'Lunas' : b.status === 'CANCELLED' ? 'Batal' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: '0.85rem' }}>{b.paymentMethod || '-'}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{b.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateStatus(b.id, 'PAID')}
|
||||||
|
className={styles.approveBtn}
|
||||||
|
>
|
||||||
|
✓ Konfirmasi Lunas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateStatus(b.id, 'CANCELLED')}
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
>
|
||||||
|
✗ Batalkan
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{b.status === 'PAID' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateStatus(b.id, 'CANCELLED')}
|
||||||
|
className={styles.cancelBtn}
|
||||||
|
>
|
||||||
|
✗ Batalkan & Lepas Kursi
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{b.status === 'CANCELLED' && (
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-muted)' }}>Tidak ada aksi</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statIcon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableSection {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeText {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsText {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaBus, FaChartBar, FaMapMarkedAlt, FaCalendarAlt, FaTicketAlt, FaShuttleVan, FaCog, FaSignOutAlt } from 'react-icons/fa';
|
||||||
|
import styles from './AdminLayout.module.css';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [adminUser, setAdminUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [brandSettings, setBrandSettings] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/session');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.user?.role === 'ADMIN') {
|
||||||
|
setAdminUser(data.user);
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
router.push('/login');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkSession();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBrand() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setBrandSettings(data.settings);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBrand();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat Sesi Admin...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.adminLayout}>
|
||||||
|
<aside className={styles.sidebar}>
|
||||||
|
<div className={styles.sidebarBrand}>
|
||||||
|
{brandSettings?.logoImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={brandSettings.logoImageUrl} alt="Logo" style={{ height: '32px', marginRight: '10px', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.brandIcon}>{brandSettings?.logoIcon || <FaBus />}</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3>{brandSettings?.brandName || 'AntarKota'}</h3>
|
||||||
|
<span className={styles.adminBadge}>Admin Panel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className={styles.sidebarNav}>
|
||||||
|
<Link href="/admin" className={`${styles.navLink} ${pathname === '/admin' ? styles.active : ''}`}>
|
||||||
|
<span className={styles.navIcon}><FaChartBar /></span> Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/routes" className={`${styles.navLink} ${pathname.startsWith('/admin/routes') ? styles.active : ''}`}>
|
||||||
|
<span className={styles.navIcon}><FaMapMarkedAlt /></span> Kelola Rute
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/schedules" className={`${styles.navLink} ${pathname.startsWith('/admin/schedules') ? styles.active : ''}`}>
|
||||||
|
<span className={styles.navIcon}><FaCalendarAlt /></span> Kelola Jadwal
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/bookings" className={`${styles.navLink} ${pathname.startsWith('/admin/bookings') ? styles.active : ''}`}>
|
||||||
|
<span className={styles.navIcon}><FaTicketAlt /></span> Kelola Tiket
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/vehicles" className={`${styles.navLink} ${pathname.startsWith('/admin/vehicles') ? styles.active : ''}`}>
|
||||||
|
<span className={styles.navIcon}><FaShuttleVan /></span> Tata Letak Kursi
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/settings" className={`${styles.navLink} ${pathname.startsWith('/admin/settings') ? styles.active : ''}`}>
|
||||||
|
<span className={styles.navIcon}><FaCog /></span> Pengaturan Brand
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={styles.sidebarFooter}>
|
||||||
|
{adminUser && (
|
||||||
|
<div className={styles.adminInfo}>
|
||||||
|
<div className={styles.adminAvatar}>A</div>
|
||||||
|
<div>
|
||||||
|
<p className={styles.adminName}>{adminUser.name}</p>
|
||||||
|
<p className={styles.adminEmail}>{adminUser.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button onClick={handleLogout} className={styles.logoutBtn}>
|
||||||
|
<FaSignOutAlt style={{ marginRight: '6px' }} /> Logout Admin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className={styles.mainContent}>
|
||||||
|
<header className={styles.topHeader}>
|
||||||
|
<h2>
|
||||||
|
{pathname === '/admin'
|
||||||
|
? 'Dashboard Overview'
|
||||||
|
: pathname.startsWith('/admin/routes')
|
||||||
|
? 'Kelola Rute Perjalanan'
|
||||||
|
: pathname.startsWith('/admin/schedules')
|
||||||
|
? 'Kelola Jadwal Keberangkatan'
|
||||||
|
: pathname.startsWith('/admin/vehicles')
|
||||||
|
? 'Tata Letak Kursi Armada'
|
||||||
|
: pathname.startsWith('/admin/settings')
|
||||||
|
? 'Pengaturan Brand & Pembayaran'
|
||||||
|
: 'Kelola Pemesanan Tiket'}
|
||||||
|
</h2>
|
||||||
|
<div className={styles.dateDisplay}>
|
||||||
|
{new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={styles.pageContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaMoneyBillWave, FaTicketAlt, FaHourglass, FaMapMarkedAlt } from 'react-icons/fa';
|
||||||
|
import styles from './dashboard.module.css';
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalRevenue: number;
|
||||||
|
paidBookingsCount: number;
|
||||||
|
pendingBookingsCount: number;
|
||||||
|
routesCount: number;
|
||||||
|
schedulesCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: number;
|
||||||
|
bookingCode: string;
|
||||||
|
passengerName: string;
|
||||||
|
passengerEmail: string;
|
||||||
|
passengerPhone: string;
|
||||||
|
totalPrice: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
seats: { seatNumber: string }[];
|
||||||
|
schedule: {
|
||||||
|
departureTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
route: {
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchDashboardData() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/stats');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal mengambil data dashboard.');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setStats(data.stats);
|
||||||
|
setRecentBookings(data.recentBookings);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Terjadi kesalahan server.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Mengambil statistik data...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className={styles.error}>{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.dashboard}>
|
||||||
|
{/* Stats Cards Row */}
|
||||||
|
{stats && (
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<div className={styles.statIcon} style={{ background: 'rgba(16, 185, 129, 0.1)', color: 'var(--accent-emerald)' }}><FaMoneyBillWave /></div>
|
||||||
|
<div className={styles.statInfo}>
|
||||||
|
<span className={styles.statLabel}>Total Pendapatan</span>
|
||||||
|
<h3 className={styles.statValue}>{formatCurrency(stats.totalRevenue)}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<div className={styles.statIcon} style={{ background: 'rgba(99, 102, 241, 0.1)', color: 'var(--text-white)' }}><FaTicketAlt /></div>
|
||||||
|
<div className={styles.statInfo}>
|
||||||
|
<span className={styles.statLabel}>Tiket Terjual</span>
|
||||||
|
<h3 className={styles.statValue}>{stats.paidBookingsCount} Booking</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<div className={styles.statIcon} style={{ background: 'rgba(245, 158, 11, 0.1)', color: 'var(--accent-amber)' }}><FaHourglass /></div>
|
||||||
|
<div className={styles.statInfo}>
|
||||||
|
<span className={styles.statLabel}>Menunggu Bayar</span>
|
||||||
|
<h3 className={styles.statValue}>{stats.pendingBookingsCount} Booking</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<div className={styles.statIcon} style={{ background: 'rgba(255, 255, 255, 0.05)', color: 'var(--text-white)' }}><FaMapMarkedAlt /></div>
|
||||||
|
<div className={styles.statInfo}>
|
||||||
|
<span className={styles.statLabel}>Jumlah Rute</span>
|
||||||
|
<h3 className={styles.statValue}>{stats.routesCount} Aktif</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Transactions Table Section */}
|
||||||
|
<div className={`${styles.tableSection} glass-panel`}>
|
||||||
|
<div className={styles.tableHeader}>
|
||||||
|
<h3>Pemesanan Tiket Terbaru</h3>
|
||||||
|
<Link href="/admin/bookings" className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '0.8rem' }}>
|
||||||
|
Lihat Semua Tiket
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recentBookings.length === 0 ? (
|
||||||
|
<p className={styles.emptyText}>Belum ada pemesanan tiket masuk.</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Kode</th>
|
||||||
|
<th>Penumpang</th>
|
||||||
|
<th>Rute Keberangkatan</th>
|
||||||
|
<th>Kursi</th>
|
||||||
|
<th>Total Biaya</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Tanggal</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recentBookings.map((b) => (
|
||||||
|
<tr key={b.id}>
|
||||||
|
<td>
|
||||||
|
<strong className={styles.codeText}>{b.bookingCode}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontWeight: 600, color: 'var(--text-white)' }}>{b.passengerName}</p>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{b.passengerPhone}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>
|
||||||
|
<p style={{ color: 'var(--text-white)' }}>{b.schedule.route.departureCity} → {b.schedule.route.arrivalCity}</p>
|
||||||
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{b.schedule.vehicleType}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.seatsText}>{b.seats.map(s => s.seatNumber).join(', ')}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-white)' }}>{formatCurrency(b.totalPrice)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`badge ${b.status === 'PAID' ? 'badge-paid' : b.status === 'CANCELLED' ? 'badge-cancelled' : 'badge-pending'}`}>
|
||||||
|
{b.status === 'PAID' ? 'Lunas' : b.status === 'CANCELLED' ? 'Batal' : 'Pending'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: '0.85rem' }}>{formatDate(b.createdAt)}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaEdit, FaTrash, FaPlus } from 'react-icons/fa';
|
||||||
|
import styles from './routes.module.css';
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
id: number;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
basePrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminRoutes() {
|
||||||
|
const [routes, setRoutes] = useState<Route[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [departureCity, setDepartureCity] = useState('');
|
||||||
|
const [arrivalCity, setArrivalCity] = useState('');
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState('');
|
||||||
|
const [basePrice, setBasePrice] = useState('');
|
||||||
|
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoutes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchRoutes() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/routes');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal mengambil data rute.');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setRoutes(data.routes);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Terjadi kesalahan sistem.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setDepartureCity('');
|
||||||
|
setArrivalCity('');
|
||||||
|
setDurationMinutes('');
|
||||||
|
setBasePrice('');
|
||||||
|
setFormError('');
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (route: Route) => {
|
||||||
|
setEditingId(route.id);
|
||||||
|
setDepartureCity(route.departureCity);
|
||||||
|
setArrivalCity(route.arrivalCity);
|
||||||
|
setDurationMinutes(route.durationMinutes.toString());
|
||||||
|
setBasePrice(route.basePrice.toString());
|
||||||
|
setFormError('');
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus rute perjalanan ini? Jadwal yang menggunakan rute ini juga mungkin terpengaruh.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/routes/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal menghapus rute.');
|
||||||
|
}
|
||||||
|
setRoutes(routes.filter(r => r.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Kesalahan sistem saat menghapus.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!departureCity || !arrivalCity || !durationMinutes || !basePrice) {
|
||||||
|
setFormError('Semua field wajib diisi.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormError('');
|
||||||
|
setFormSaving(true);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
departureCity,
|
||||||
|
arrivalCity,
|
||||||
|
durationMinutes: Number(durationMinutes),
|
||||||
|
basePrice: Number(basePrice),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = editingId ? `/api/admin/routes/${editingId}` : '/api/admin/routes';
|
||||||
|
const method = editingId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Gagal menyimpan data rute.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
setRoutes(routes.map(r => r.id === editingId ? data.route : r));
|
||||||
|
} else {
|
||||||
|
setRoutes([data.route, ...routes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setFormError(err.message || 'Gagal memproses permintaan.');
|
||||||
|
} finally {
|
||||||
|
setFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat data rute...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<p className={styles.subtitle}>Daftar rute perjalanan shuttle dan bis yang aktif beroperasi.</p>
|
||||||
|
<button onClick={handleCreateNew} className="btn btn-primary">
|
||||||
|
<FaPlus style={{ marginRight: '6px' }} /> Tambah Rute Baru
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
{/* Routes list Table */}
|
||||||
|
<div className="glass-panel" style={{ padding: '24px', overflowX: 'auto' }}>
|
||||||
|
{routes.length === 0 ? (
|
||||||
|
<p className={styles.emptyText}>Belum ada rute perjalanan. Silakan tambah rute baru.</p>
|
||||||
|
) : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>No. Rute</th>
|
||||||
|
<th>Kota Asal</th>
|
||||||
|
<th>Kota Tujuan</th>
|
||||||
|
<th>Estimasi Durasi</th>
|
||||||
|
<th>Harga Dasar</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{routes.map((route) => (
|
||||||
|
<tr key={route.id}>
|
||||||
|
<td>
|
||||||
|
<span className={styles.routeId}>#{route.id}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong style={{ color: 'var(--text-white)' }}>{route.departureCity}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong style={{ color: 'var(--text-white)' }}>{route.arrivalCity}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span>{Math.floor(route.durationMinutes / 60)} Jam {route.durationMinutes % 60} Menit</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-white)' }}>{formatCurrency(route.basePrice)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button onClick={() => handleEdit(route)} className={styles.editBtn}>
|
||||||
|
<FaEdit style={{ marginRight: '4px' }} /> Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(route.id)} className={styles.deleteBtn}>
|
||||||
|
<FaTrash style={{ marginRight: '4px' }} /> Hapus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slide-out Overlay Modal Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={`${styles.modal} glass-panel animate-fade-in`}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h3>{editingId ? 'Edit Rute Perjalanan' : 'Tambah Rute Baru'}</h3>
|
||||||
|
<button onClick={() => setShowForm(false)} className={styles.closeBtn}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
{formError && <div className={styles.errorAlert}>{formError}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Kota Asal Keberangkatan</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: Jakarta"
|
||||||
|
value={departureCity}
|
||||||
|
onChange={(e) => setDepartureCity(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Kota Tujuan Kedatangan</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: Bandung"
|
||||||
|
value={arrivalCity}
|
||||||
|
onChange={(e) => setArrivalCity(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Durasi Perjalanan (dalam Menit)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: 180"
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(e) => setDurationMinutes(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Tarif Dasar Tiket (Rupiah)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: 130000"
|
||||||
|
value={basePrice}
|
||||||
|
onChange={(e) => setBasePrice(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="btn btn-secondary">
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={formSaving} className="btn btn-primary">
|
||||||
|
{formSaving ? 'Menyimpan...' : 'Simpan Rute'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeId {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
color: var(--accent-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:hover {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styling */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaExclamationTriangle, FaEdit, FaTrash, FaPlus } from 'react-icons/fa';
|
||||||
|
import styles from './schedules.module.css';
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
id: number;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
basePrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Schedule {
|
||||||
|
id: number;
|
||||||
|
routeId: number;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
capacity: number;
|
||||||
|
price: number;
|
||||||
|
route: {
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSchedules() {
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [routes, setRoutes] = useState<Route[]>([]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [routeId, setRouteId] = useState('');
|
||||||
|
const [departureTime, setDepartureTime] = useState('');
|
||||||
|
const [arrivalTime, setArrivalTime] = useState('');
|
||||||
|
const [vehicleType, setVehicleType] = useState('Toyota HiAce (10 Seater)');
|
||||||
|
const [capacity, setCapacity] = useState('10');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
const [vehicleLayouts, setVehicleLayouts] = useState<any[]>([]);
|
||||||
|
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
// Fetch schedules
|
||||||
|
const resSchedules = await fetch('/api/admin/schedules');
|
||||||
|
if (!resSchedules.ok) throw new Error('Gagal mengambil data jadwal.');
|
||||||
|
const dataSchedules = await resSchedules.json();
|
||||||
|
setSchedules(dataSchedules.schedules);
|
||||||
|
|
||||||
|
// Fetch routes for dropdown
|
||||||
|
const resRoutes = await fetch('/api/admin/routes');
|
||||||
|
if (!resRoutes.ok) throw new Error('Gagal mengambil data rute.');
|
||||||
|
const dataRoutes = await resRoutes.json();
|
||||||
|
setRoutes(dataRoutes.routes);
|
||||||
|
|
||||||
|
// Fetch vehicle layouts
|
||||||
|
const resLayouts = await fetch('/api/admin/vehicles');
|
||||||
|
if (resLayouts.ok) {
|
||||||
|
const dataLayouts = await resLayouts.json();
|
||||||
|
setVehicleLayouts(dataLayouts.layouts);
|
||||||
|
if (dataLayouts.layouts.length > 0) {
|
||||||
|
setVehicleType(dataLayouts.layouts[0].name);
|
||||||
|
setCapacity(dataLayouts.layouts[0].capacity.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Terjadi kesalahan sistem.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle vehicle type change to set capacity
|
||||||
|
const handleVehicleChange = (vType: string, layoutsList = vehicleLayouts) => {
|
||||||
|
setVehicleType(vType);
|
||||||
|
const match = layoutsList.find(l => l.name === vType);
|
||||||
|
if (match) {
|
||||||
|
setCapacity(match.capacity.toString());
|
||||||
|
} else {
|
||||||
|
if (vType.toLowerCase().includes('hiace')) {
|
||||||
|
setCapacity('10');
|
||||||
|
} else {
|
||||||
|
setCapacity('30');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRouteSelect = (rId: string) => {
|
||||||
|
setRouteId(rId);
|
||||||
|
const selected = routes.find(r => r.id === Number(rId));
|
||||||
|
if (selected) {
|
||||||
|
setPrice(selected.basePrice.toString());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNew = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setRouteId(routes[0]?.id.toString() || '');
|
||||||
|
setDepartureTime('');
|
||||||
|
setArrivalTime('');
|
||||||
|
const defaultVehicle = vehicleLayouts[0]?.name || 'Toyota HiAce (10 Seater)';
|
||||||
|
const defaultCapacity = vehicleLayouts[0]?.capacity?.toString() || '10';
|
||||||
|
setVehicleType(defaultVehicle);
|
||||||
|
setCapacity(defaultCapacity);
|
||||||
|
setPrice(routes[0]?.basePrice.toString() || '');
|
||||||
|
setFormError('');
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (schedule: Schedule) => {
|
||||||
|
// Format dates to YYYY-MM-DDTHH:MM (ignoring UTC conversion for simplicity or keeping exact timezone)
|
||||||
|
// To match datetime-local inputs, we format it as local ISO substring: YYYY-MM-DDTHH:MM
|
||||||
|
const depDate = new Date(schedule.departureTime);
|
||||||
|
const depStr = new Date(depDate.getTime() - depDate.getTimezoneOffset() * 60000).toISOString().substring(0, 16);
|
||||||
|
|
||||||
|
const arrDate = new Date(schedule.arrivalTime);
|
||||||
|
const arrStr = new Date(arrDate.getTime() - arrDate.getTimezoneOffset() * 60000).toISOString().substring(0, 16);
|
||||||
|
|
||||||
|
setEditingId(schedule.id);
|
||||||
|
setRouteId(schedule.routeId.toString());
|
||||||
|
setDepartureTime(depStr);
|
||||||
|
setArrivalTime(arrStr);
|
||||||
|
setVehicleType(schedule.vehicleType);
|
||||||
|
setCapacity(schedule.capacity.toString());
|
||||||
|
setPrice(schedule.price.toString());
|
||||||
|
setFormError('');
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus jadwal keberangkatan ini? Semua pemesanan tiket pada jadwal ini akan ikut terhapus.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/schedules/${id}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error('Gagal menghapus jadwal.');
|
||||||
|
setSchedules(schedules.filter(s => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Gagal memproses permintaan.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!routeId || !departureTime || !arrivalTime || !vehicleType || !capacity || !price) {
|
||||||
|
setFormError('Semua field wajib diisi.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const depDate = new Date(departureTime);
|
||||||
|
const arrDate = new Date(arrivalTime);
|
||||||
|
|
||||||
|
if (arrDate.getTime() <= depDate.getTime()) {
|
||||||
|
setFormError('Waktu kedatangan harus setelah waktu keberangkatan.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormError('');
|
||||||
|
setFormSaving(true);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
routeId: Number(routeId),
|
||||||
|
departureTime: depDate.toISOString(),
|
||||||
|
arrivalTime: arrDate.toISOString(),
|
||||||
|
vehicleType,
|
||||||
|
capacity: Number(capacity),
|
||||||
|
price: Number(price),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = editingId ? `/api/admin/schedules/${editingId}` : '/api/admin/schedules';
|
||||||
|
const method = editingId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Gagal menyimpan jadwal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
setSchedules(schedules.map(s => s.id === editingId ? data.schedule : s));
|
||||||
|
} else {
|
||||||
|
setSchedules([data.schedule, ...schedules]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowForm(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setFormError(err.message || 'Gagal memproses permintaan.');
|
||||||
|
} finally {
|
||||||
|
setFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateDisplay = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) +
|
||||||
|
' ' +
|
||||||
|
d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' }) + ' WIB';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat data jadwal...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<p className={styles.subtitle}>Daftar jadwal harian keberangkatan armada shuttle dan bus.</p>
|
||||||
|
<button onClick={handleCreateNew} disabled={routes.length === 0} className="btn btn-primary">
|
||||||
|
<FaPlus style={{ marginRight: '6px' }} /> Tambah Jadwal Baru
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
{routes.length === 0 && (
|
||||||
|
<div className={styles.errorAlert} style={{ background: 'rgba(245, 158, 11, 0.1)', color: 'var(--accent-amber)', borderColor: 'rgba(245, 158, 11, 0.2)' }}>
|
||||||
|
<FaExclamationTriangle style={{ marginRight: '6px' }} /> Belum ada rute perjalanan yang aktif. Silakan tambahkan rute perjalanan terlebih dahulu sebelum membuat jadwal.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schedules list Table */}
|
||||||
|
<div className="glass-panel" style={{ padding: '24px', overflowX: 'auto' }}>
|
||||||
|
{schedules.length === 0 ? (
|
||||||
|
<p className={styles.emptyText}>Belum ada jadwal keberangkatan.</p>
|
||||||
|
) : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rute</th>
|
||||||
|
<th>Keberangkatan</th>
|
||||||
|
<th>Kedatangan</th>
|
||||||
|
<th>Armada</th>
|
||||||
|
<th>Kapasitas</th>
|
||||||
|
<th>Tarif Tiket</th>
|
||||||
|
<th>Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{schedules.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>
|
||||||
|
<strong style={{ color: 'var(--text-white)' }}>{s.route.departureCity} → {s.route.arrivalCity}</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: '0.85rem' }}>{formatDateDisplay(s.departureTime)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: '0.85rem' }}>{formatDateDisplay(s.arrivalTime)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.vehicleType}>{s.vehicleType}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.capacityBadge}>{s.capacity} Kursi</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontWeight: 600, color: 'var(--text-white)' }}>{formatCurrency(s.price)}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button onClick={() => handleEdit(s)} className={styles.editBtn}>
|
||||||
|
<FaEdit style={{ marginRight: '4px' }} /> Edit
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(s.id)} className={styles.deleteBtn}>
|
||||||
|
<FaTrash style={{ marginRight: '4px' }} /> Hapus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay Modal Form */}
|
||||||
|
{showForm && (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={`${styles.modal} glass-panel animate-fade-in`}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<h3>{editingId ? 'Edit Jadwal Perjalanan' : 'Tambah Jadwal Baru'}</h3>
|
||||||
|
<button onClick={() => setShowForm(false)} className={styles.closeBtn}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
{formError && <div className={styles.errorAlert}>{formError}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Pilih Rute Perjalanan</label>
|
||||||
|
<select
|
||||||
|
value={routeId}
|
||||||
|
onChange={(e) => handleRouteSelect(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
{routes.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.departureCity} → {r.arrivalCity} (Dasar: {formatCurrency(r.basePrice)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Tipe Armada</label>
|
||||||
|
<select
|
||||||
|
value={vehicleType}
|
||||||
|
onChange={(e) => handleVehicleChange(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
{vehicleLayouts.length > 0 ? (
|
||||||
|
vehicleLayouts.map(l => (
|
||||||
|
<option key={l.name} value={l.name}>{l.name}</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="Toyota HiAce (10 Seater)">Toyota HiAce (10 Seater)</option>
|
||||||
|
<option value="Executive Bus (30 Seater)">Executive Bus (30 Seater)</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Waktu Keberangkatan</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
value={departureTime}
|
||||||
|
onChange={(e) => setDepartureTime(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Waktu Estimasi Kedatangan</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
value={arrivalTime}
|
||||||
|
onChange={(e) => setArrivalTime(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Kapasitas Kursi (Otomatis)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
disabled
|
||||||
|
value={capacity}
|
||||||
|
className="form-input"
|
||||||
|
style={{ opacity: 0.6, cursor: 'not-allowed' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Harga Tiket Keberangkatan (Rupiah)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
placeholder="Tarif Tiket..."
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.modalActions}>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="btn btn-secondary">
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button type="submit" disabled={formSaving} className="btn btn-primary">
|
||||||
|
{formSaving ? 'Menyimpan...' : 'Simpan Jadwal'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleType {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacityBadge {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn,
|
||||||
|
.deleteBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBtn:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn {
|
||||||
|
color: var(--accent-rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleteBtn:hover {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyText {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal styling */
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 30px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalHeader h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modalActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaImage, FaTrash, FaPhone, FaCreditCard, FaEnvelope } from 'react-icons/fa';
|
||||||
|
import styles from './settings.module.css';
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
brandName: string;
|
||||||
|
logoIcon: string;
|
||||||
|
logoHighlight: string;
|
||||||
|
logoImageUrl: string;
|
||||||
|
primaryColor: string;
|
||||||
|
csPhone: string;
|
||||||
|
csWhatsapp: string;
|
||||||
|
csEmail: string;
|
||||||
|
pakasirSlug: string;
|
||||||
|
pakasirApiKey: string;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: string;
|
||||||
|
smtpUser: string;
|
||||||
|
smtpPassword: string;
|
||||||
|
smtpSenderName: string;
|
||||||
|
smtpSenderEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminSettings() {
|
||||||
|
const [settings, setSettings] = useState<Settings>({
|
||||||
|
brandName: '',
|
||||||
|
logoIcon: '',
|
||||||
|
logoHighlight: '',
|
||||||
|
logoImageUrl: '',
|
||||||
|
primaryColor: '',
|
||||||
|
csPhone: '',
|
||||||
|
csWhatsapp: '',
|
||||||
|
csEmail: '',
|
||||||
|
pakasirSlug: '',
|
||||||
|
pakasirApiKey: '',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: '',
|
||||||
|
smtpUser: '',
|
||||||
|
smtpPassword: '',
|
||||||
|
smtpSenderName: '',
|
||||||
|
smtpSenderEmail: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
|
const [deleteLogoImage, setDeleteLogoImage] = useState(false);
|
||||||
|
|
||||||
|
const [callbackUrl, setCallbackUrl] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setCallbackUrl(`${window.location.origin}/api/webhook/pakasir`);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchSettings() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal mengambil data pengaturan.');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setSettings(data.settings);
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err.message || 'Terjadi kesalahan server.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setSettings((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setSuccessMsg('');
|
||||||
|
setErrorMsg('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('brandName', settings.brandName);
|
||||||
|
formData.append('logoIcon', settings.logoIcon);
|
||||||
|
formData.append('logoHighlight', settings.logoHighlight);
|
||||||
|
formData.append('primaryColor', settings.primaryColor);
|
||||||
|
formData.append('csPhone', settings.csPhone);
|
||||||
|
formData.append('csWhatsapp', settings.csWhatsapp);
|
||||||
|
formData.append('csEmail', settings.csEmail);
|
||||||
|
formData.append('pakasirSlug', settings.pakasirSlug);
|
||||||
|
formData.append('pakasirApiKey', settings.pakasirApiKey);
|
||||||
|
formData.append('smtpHost', settings.smtpHost || '');
|
||||||
|
formData.append('smtpPort', settings.smtpPort || '587');
|
||||||
|
formData.append('smtpUser', settings.smtpUser || '');
|
||||||
|
formData.append('smtpPassword', settings.smtpPassword || '');
|
||||||
|
formData.append('smtpSenderName', settings.smtpSenderName || '');
|
||||||
|
formData.append('smtpSenderEmail', settings.smtpSenderEmail || '');
|
||||||
|
|
||||||
|
if (logoFile) {
|
||||||
|
formData.append('logoFile', logoFile);
|
||||||
|
}
|
||||||
|
if (deleteLogoImage) {
|
||||||
|
formData.append('deleteLogoImage', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Gagal menyimpan pengaturan.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccessMsg('Pengaturan brand dan pembayaran berhasil disimpan!');
|
||||||
|
setLogoFile(null);
|
||||||
|
setDeleteLogoImage(false);
|
||||||
|
|
||||||
|
// Re-fetch updated settings
|
||||||
|
const refetchRes = await fetch('/api/admin/settings');
|
||||||
|
if (refetchRes.ok) {
|
||||||
|
const data = await refetchRes.json();
|
||||||
|
setSettings(data.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide success message after 3 seconds
|
||||||
|
setTimeout(() => setSuccessMsg(''), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err.message || 'Gagal menyimpan pengaturan.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat pengaturan...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{successMsg && <div className={styles.successAlert}>{successMsg}</div>}
|
||||||
|
{errorMsg && <div className={styles.errorAlert}>{errorMsg}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.container}>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
{/* Card 1: Branding */}
|
||||||
|
<div className={`${styles.sectionCard} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle}>🎨 Identitas Brand</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nama Brand Utama</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="brandName"
|
||||||
|
value={settings.brandName}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: AntarKota"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||||
|
<label className="form-label">Upload Logo Image (PNG/JPG)</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '15px', marginTop: '10px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flexShrink: 0
|
||||||
|
}}>
|
||||||
|
{logoFile ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={URL.createObjectURL(logoFile)} alt="Logo Preview" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
|
) : settings.logoImageUrl && !deleteLogoImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={settings.logoImageUrl} alt="Logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<span style={{ fontSize: '1.5rem', color: 'var(--text-muted)' }}><FaImage /></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
id="logo-upload-input"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setLogoFile(e.target.files[0]);
|
||||||
|
setDeleteLogoImage(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="logo-upload-input" className="btn btn-secondary" style={{ display: 'inline-block', cursor: 'pointer', padding: '6px 12px', fontSize: '0.85rem', margin: 0 }}>
|
||||||
|
📁 Pilih Gambar Logo
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{((settings.logoImageUrl && !deleteLogoImage) || logoFile) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setLogoFile(null);
|
||||||
|
if (settings.logoImageUrl) {
|
||||||
|
setDeleteLogoImage(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="btn"
|
||||||
|
style={{ padding: '6px 12px', fontSize: '0.85rem', border: '1px solid rgba(244,63,94,0.3)', color: 'var(--accent-rose)', background: 'transparent' }}
|
||||||
|
>
|
||||||
|
<FaTrash style={{ marginRight: '4px' }} /> Hapus Logo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Emoji Icon Logo (Fallback)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="logoIcon"
|
||||||
|
value={settings.logoIcon}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: 🚌"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Teks Highlight Logo (Suffix)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="logoHighlight"
|
||||||
|
value={settings.logoHighlight}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: Kota"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Warna Utama Brand (Color Scheme)</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name="primaryColor"
|
||||||
|
value={settings.primaryColor || '#6366f1'}
|
||||||
|
onChange={handleChange}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
width: '44px',
|
||||||
|
height: '44px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="primaryColor"
|
||||||
|
value={settings.primaryColor || '#6366f1'}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="#6366f1"
|
||||||
|
style={{ maxWidth: '120px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Card 2: Customer Service */}
|
||||||
|
<div className={`${styles.sectionCard} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle}><FaPhone style={{ marginRight: '8px' }} /> Kontak Customer Service</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nomor Call Center</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="csPhone"
|
||||||
|
value={settings.csPhone}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: 0804-1-808-808"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nomor WhatsApp</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="csWhatsapp"
|
||||||
|
value={settings.csWhatsapp}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: +62 812-3456-7890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Alamat Email Support</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="csEmail"
|
||||||
|
value={settings.csEmail}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: support@antarkota.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Pakasir Integration */}
|
||||||
|
<div className={`${styles.sectionCard} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle}><FaCreditCard style={{ marginRight: '8px' }} /> Integrasi QRIS Pakasir</h3>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '15px' }}>
|
||||||
|
Masukkan slug proyek dan API Key Anda dari dashboard <a href="https://pakasir.com" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-white)', textDecoration: 'underline' }}>Pakasir</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Proyek Slug (Slug Project)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="pakasirSlug"
|
||||||
|
value={settings.pakasirSlug}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: depodomain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Pakasir API Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="pakasirApiKey"
|
||||||
|
value={settings.pakasirApiKey}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Masukkan API Key Pakasir Anda..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '18px', padding: '12px', background: 'rgba(255,255,255,0.02)', borderRadius: '8px', border: '1px solid var(--border-light)', fontSize: '0.85rem' }}>
|
||||||
|
<span style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: '6px', fontWeight: 600 }}>🔗 URL Webhook Callback Pakasir:</span>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={callbackUrl || 'http://yourdomain.com/api/webhook/pakasir'}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.2)',
|
||||||
|
border: '1px solid var(--border-light)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(callbackUrl);
|
||||||
|
alert('URL Callback berhasil disalin!');
|
||||||
|
}}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '8px 12px', fontSize: '0.8rem', margin: 0, whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Salin URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: '8px 0 0 0', fontSize: '0.75rem', color: 'var(--text-muted)' }}>
|
||||||
|
Daftarkan URL di atas pada dashboard Pakasir untuk mendeteksi pembayaran QRIS otomatis.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4: SMTP Configuration */}
|
||||||
|
<div className={`${styles.sectionCard} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle}><FaEnvelope style={{ marginRight: '8px' }} /> Pengaturan Email (SMTP)</h3>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '15px' }}>
|
||||||
|
Konfigurasi kredensial SMTP untuk mengirimkan e-ticket otomatis setelah pembayaran berhasil.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Host SMTP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="smtpHost"
|
||||||
|
value={settings.smtpHost || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: smtp.gmail.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Port SMTP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="smtpPort"
|
||||||
|
value={settings.smtpPort || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: 587 atau 465"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Username SMTP</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="smtpUser"
|
||||||
|
value={settings.smtpUser || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: email-anda@gmail.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password SMTP</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="smtpPassword"
|
||||||
|
value={settings.smtpPassword || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Masukkan password / App Password..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nama Pengirim</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="smtpSenderName"
|
||||||
|
value={settings.smtpSenderName || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: AntarKota Travel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Email Pengirim</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="smtpSenderEmail"
|
||||||
|
value={settings.smtpSenderEmail || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: noreply@antarkota.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ minWidth: '150px' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Menyimpan...' : 'Simpan Perubahan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionCard {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successAlert {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaShuttleVan, FaBus, FaEdit, FaTrash, FaCog, FaDesktop, FaPlus } from 'react-icons/fa';
|
||||||
|
import styles from './vehicles.module.css';
|
||||||
|
|
||||||
|
interface Seat {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VehicleLayout {
|
||||||
|
name: string;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
capacity: number;
|
||||||
|
seats: Seat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminVehicles() {
|
||||||
|
const [layouts, setLayouts] = useState<VehicleLayout[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
const [successMsg, setSuccessMsg] = useState('');
|
||||||
|
|
||||||
|
// Editor states
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null); // null means creating new
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editRows, setEditRows] = useState(5);
|
||||||
|
const [editCols, setEditCols] = useState(4);
|
||||||
|
const [editSeats, setEditSeats] = useState<Seat[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLayouts() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/vehicles');
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal mengambil data tata letak armada.');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setLayouts(data.layouts);
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err.message || 'Terjadi kesalahan server.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchLayouts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStartCreate = () => {
|
||||||
|
setEditIndex(null);
|
||||||
|
setEditName('');
|
||||||
|
setEditRows(5);
|
||||||
|
setEditCols(4);
|
||||||
|
setEditSeats([]);
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartEdit = (index: number) => {
|
||||||
|
const layout = layouts[index];
|
||||||
|
setEditIndex(index);
|
||||||
|
setEditName(layout.name);
|
||||||
|
setEditRows(layout.rows);
|
||||||
|
setEditCols(layout.cols);
|
||||||
|
setEditSeats([...layout.seats]);
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteLayout = async (index: number) => {
|
||||||
|
if (!confirm('Apakah Anda yakin ingin menghapus tata letak ini?')) return;
|
||||||
|
|
||||||
|
const updated = layouts.filter((_, idx) => idx !== index);
|
||||||
|
await saveLayoutsList(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveLayoutsList = async (updatedList: VehicleLayout[]) => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrorMsg('');
|
||||||
|
setSuccessMsg('');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/vehicles', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ layouts: updatedList }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error || 'Gagal menyimpan perubahan armada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setLayouts(updatedList);
|
||||||
|
setSuccessMsg('Tata letak armada berhasil disimpan!');
|
||||||
|
setTimeout(() => setSuccessMsg(''), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setErrorMsg(err.message || 'Terjadi kesalahan saat menyimpan.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEditor = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!editName.trim()) {
|
||||||
|
setErrorMsg('Nama tipe kendaraan harus diisi.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editSeats.length === 0) {
|
||||||
|
setErrorMsg('Harus ada minimal 1 kursi yang diaktifkan pada grid.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter seats that fall outside of resized grid
|
||||||
|
const filteredSeats = editSeats.filter(
|
||||||
|
(seat) => seat.row < editRows && seat.col < editCols
|
||||||
|
);
|
||||||
|
|
||||||
|
const newLayout: VehicleLayout = {
|
||||||
|
name: editName,
|
||||||
|
rows: Number(editRows),
|
||||||
|
cols: Number(editCols),
|
||||||
|
capacity: filteredSeats.length,
|
||||||
|
seats: filteredSeats,
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = [...layouts];
|
||||||
|
if (editIndex === null) {
|
||||||
|
updated.push(newLayout);
|
||||||
|
} else {
|
||||||
|
updated[editIndex] = newLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditing(false);
|
||||||
|
await saveLayoutsList(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grid editing utilities
|
||||||
|
const isSeatAt = (row: number, col: number) => {
|
||||||
|
return editSeats.find((s) => s.row === row && s.col === col);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleCell = (row: number, col: number) => {
|
||||||
|
const existing = isSeatAt(row, col);
|
||||||
|
if (existing) {
|
||||||
|
// Remove seat
|
||||||
|
setEditSeats(editSeats.filter((s) => !(s.row === row && s.col === col)));
|
||||||
|
} else {
|
||||||
|
// Prompt for label
|
||||||
|
const nextNum = editSeats.length + 1;
|
||||||
|
const labelInput = prompt('Masukkan nomor / label kursi:', String(nextNum));
|
||||||
|
if (labelInput === null) return; // User cancelled
|
||||||
|
|
||||||
|
const newSeat: Seat = {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
label: labelInput.trim() || String(nextNum),
|
||||||
|
};
|
||||||
|
setEditSeats([...editSeats, newSeat]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLabelChange = (row: number, col: number, newLabel: string) => {
|
||||||
|
setEditSeats(
|
||||||
|
editSeats.map((s) => {
|
||||||
|
if (s.row === row && s.col === col) {
|
||||||
|
return { ...s, label: newLabel };
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && layouts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat tata letak armada...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{successMsg && <div className="success-alert" style={{ marginBottom: '16px' }}>{successMsg}</div>}
|
||||||
|
{errorMsg && <div className="error-alert" style={{ marginBottom: '16px' }}>{errorMsg}</div>}
|
||||||
|
|
||||||
|
{!isEditing ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.95rem' }}>
|
||||||
|
Daftar tata letak armada dan konfigurasi kapasitas kursi untuk visual pemesanan pelanggan.
|
||||||
|
</p>
|
||||||
|
<button onClick={handleStartCreate} className="btn btn-primary">
|
||||||
|
<FaPlus style={{ marginRight: '6px' }} /> Buat Tata Letak Baru
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.layoutsGrid}>
|
||||||
|
{layouts.map((layout, idx) => (
|
||||||
|
<div key={layout.name} className={`${styles.layoutCard} glass-panel`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div>
|
||||||
|
<h4 className={styles.cardTitle}>{layout.name}</h4>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-white)', fontWeight: 600 }}>
|
||||||
|
Kapasitas: {layout.capacity} Kursi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '1.5rem' }}>
|
||||||
|
{layout.name.toLowerCase().includes('hiace') ? <FaShuttleVan /> : <FaBus />}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardStats}>
|
||||||
|
<span>Grid Ukuran: {layout.rows} Baris × {layout.cols} Kolom</span>
|
||||||
|
<span>Definisi Kursi: Aktif</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEdit(idx)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1, padding: '6px 12px', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
<FaEdit style={{ marginRight: '4px' }} /> Edit Tata Letak
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteLayout(idx)}
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
border: '1px solid rgba(244,63,94,0.3)',
|
||||||
|
color: 'var(--accent-rose)',
|
||||||
|
background: 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaTrash style={{ marginRight: '4px' }} /> Hapus
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* GRID EDITOR VIEW */
|
||||||
|
<form onSubmit={handleSaveEditor} className={styles.editorSection}>
|
||||||
|
{/* Configurations Sidebar */}
|
||||||
|
<div className={`${styles.editorForm} glass-panel`}>
|
||||||
|
<h3><FaCog style={{ marginRight: '6px' }} /> Konfigurasi Tata Letak</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nama Armada / Tipe Kendaraan</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Contoh: Toyota HiAce Premio (11 Seater)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.gridControlGroup}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Jumlah Baris (Grid Rows)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={15}
|
||||||
|
required
|
||||||
|
value={editRows}
|
||||||
|
onChange={(e) => setEditRows(Math.max(1, Number(e.target.value)))}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Jumlah Kolom (Grid Cols)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
required
|
||||||
|
value={editCols}
|
||||||
|
onChange={(e) => setEditCols(Math.max(1, Number(e.target.value)))}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(255,255,255,0.02)', borderRadius: '8px', border: '1px solid var(--border-light)' }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px 0', fontSize: '0.9rem', color: 'var(--text-white)' }}>💡 Petunjuk Editor Seating</h4>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '0.8rem', color: 'var(--text-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||||
|
<li>Klik pada grid kosong (garis putus-putus) untuk <strong>menambahkan kursi</strong>.</li>
|
||||||
|
<li>Gunakan kolom teks di dalam kursi untuk <strong>menamai nomor kursi</strong> (misal: A1, 12, VIP).</li>
|
||||||
|
<li>Klik ikon <strong>x</strong> merah di pojok kanan atas kursi untuk menghapusnya.</li>
|
||||||
|
<li>Kapasitas armada dihitung otomatis berdasarkan jumlah kursi aktif.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
|
Kapasitas Dihitung: <strong style={{ color: 'var(--text-white)' }}>{editSeats.length} Kursi</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.editorActions} style={{ marginTop: '30px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsEditing(false)}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
Simpan Tata Letak
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Seat Editor Canvas */}
|
||||||
|
<div className={`${styles.interactiveGridSection} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle} style={{ borderBottom: 'none', paddingBottom: 0, margin: 0 }}>
|
||||||
|
<FaDesktop style={{ marginRight: '6px' }} /> Visual Seating Grid Map
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className={styles.gridWrapper}>
|
||||||
|
{/* Dashboard Layout Header mimicking vehicle direction */}
|
||||||
|
<div className={styles.steeringRow}>
|
||||||
|
<div className={styles.steeringWheel}><FaCog style={{ marginRight: '4px' }} /> Roda Kemudi (Depan)</div>
|
||||||
|
<div className={styles.driverSeat}>Sopir</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid Seat Layout */}
|
||||||
|
<div
|
||||||
|
className={styles.seatingGrid}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${editCols}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: editRows }).map((_, rIdx) =>
|
||||||
|
Array.from({ length: editCols }).map((_, cIdx) => {
|
||||||
|
const seat = isSeatAt(rIdx, cIdx);
|
||||||
|
if (seat) {
|
||||||
|
return (
|
||||||
|
<div key={`${rIdx}-${cIdx}`} className={`${styles.gridCell} ${styles.cellSeat}`}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={seat.label}
|
||||||
|
onChange={(e) => handleLabelChange(rIdx, cIdx, e.target.value)}
|
||||||
|
className={styles.seatInput}
|
||||||
|
title="Klik untuk ubah label kursi"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleCell(rIdx, cIdx)}
|
||||||
|
className={styles.cellRemoveBtn}
|
||||||
|
title="Hapus kursi"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${rIdx}-${cIdx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleCell(rIdx, cIdx)}
|
||||||
|
className={`${styles.gridCell} ${styles.cellEmpty}`}
|
||||||
|
title="Klik untuk aktifkan kursi"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layoutsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layoutCard {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layoutCard:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardStats {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorSection {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.5fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.editorSection {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorForm {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorForm h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridControlGroup {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interactiveGridSection {
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridWrapper {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steeringRow {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steeringWheel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driverSeat {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatingGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridCell {
|
||||||
|
position: relative;
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellEmpty {
|
||||||
|
border: 2px dashed var(--border-light);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellEmpty:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellSeat {
|
||||||
|
background: var(--primary);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, white);
|
||||||
|
color: var(--text-white);
|
||||||
|
box-shadow: 0 0 10px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellSeat:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatInput {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cellRemoveBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: var(--accent-rose);
|
||||||
|
color: white;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridCell:hover .cellRemoveBtn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 40vh;
|
||||||
|
gap: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { sendTicketEmail } from '@/lib/email';
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = Number(params.id);
|
||||||
|
const body = await request.json();
|
||||||
|
const { status } = body; // PAID, PENDING, CANCELLED
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return NextResponse.json({ error: 'Status is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const booking = await tx.booking.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === 'CANCELLED') {
|
||||||
|
// Delete seat reservations to free them up
|
||||||
|
await tx.bookingSeat.deleteMany({
|
||||||
|
where: { bookingId: id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return booking;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (status === 'PAID') {
|
||||||
|
await sendTicketEmail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, booking: { ...updated, totalPrice: Number(updated.totalPrice) } });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
console.error('Update booking error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update booking' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = bookings.map((b) => ({
|
||||||
|
...b,
|
||||||
|
totalPrice: Number(b.totalPrice),
|
||||||
|
schedule: {
|
||||||
|
...b.schedule,
|
||||||
|
price: Number(b.schedule.price),
|
||||||
|
route: {
|
||||||
|
...b.schedule.route,
|
||||||
|
basePrice: Number(b.schedule.route.basePrice),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ bookings: normalized });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch bookings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = Number(params.id);
|
||||||
|
const body = await request.json();
|
||||||
|
const { departureCity, arrivalCity, durationMinutes, basePrice } = body;
|
||||||
|
|
||||||
|
const route = await prisma.route.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
departureCity,
|
||||||
|
arrivalCity,
|
||||||
|
durationMinutes: Number(durationMinutes),
|
||||||
|
basePrice: Number(basePrice),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, route: { ...route, basePrice: Number(route.basePrice) } });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to update route' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = Number(params.id);
|
||||||
|
|
||||||
|
await prisma.route.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to delete route' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const routes = await prisma.route.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
const normalized = routes.map((r) => ({ ...r, basePrice: Number(r.basePrice) }));
|
||||||
|
return NextResponse.json({ routes: normalized });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch routes' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const { departureCity, arrivalCity, durationMinutes, basePrice } = body;
|
||||||
|
|
||||||
|
if (!departureCity || !arrivalCity || !durationMinutes || !basePrice) {
|
||||||
|
return NextResponse.json({ error: 'Missing required route details' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await prisma.route.create({
|
||||||
|
data: {
|
||||||
|
departureCity,
|
||||||
|
arrivalCity,
|
||||||
|
durationMinutes: Number(durationMinutes),
|
||||||
|
basePrice: Number(basePrice),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, route: { ...route, basePrice: Number(route.basePrice) } });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to create route' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = Number(params.id);
|
||||||
|
const body = await request.json();
|
||||||
|
const { routeId, departureTime, arrivalTime, vehicleType, capacity, price } = body;
|
||||||
|
|
||||||
|
const schedule = await prisma.schedule.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
routeId: Number(routeId),
|
||||||
|
departureTime: new Date(departureTime),
|
||||||
|
arrivalTime: new Date(arrivalTime),
|
||||||
|
vehicleType,
|
||||||
|
capacity: Number(capacity),
|
||||||
|
price: Number(price),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
schedule: {
|
||||||
|
...schedule,
|
||||||
|
price: Number(schedule.price),
|
||||||
|
route: {
|
||||||
|
...schedule.route,
|
||||||
|
basePrice: Number(schedule.route.basePrice),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to update schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const id = Number(params.id);
|
||||||
|
|
||||||
|
await prisma.schedule.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to delete schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const schedules = await prisma.schedule.findMany({
|
||||||
|
orderBy: { departureTime: 'desc' },
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalized = schedules.map((s) => ({
|
||||||
|
...s,
|
||||||
|
price: Number(s.price),
|
||||||
|
route: {
|
||||||
|
...s.route,
|
||||||
|
basePrice: Number(s.route.basePrice),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ schedules: normalized });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch schedules' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const { routeId, departureTime, arrivalTime, vehicleType, capacity, price } = body;
|
||||||
|
|
||||||
|
if (!routeId || !departureTime || !arrivalTime || !vehicleType || !capacity || !price) {
|
||||||
|
return NextResponse.json({ error: 'Missing required schedule details' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await prisma.schedule.create({
|
||||||
|
data: {
|
||||||
|
routeId: Number(routeId),
|
||||||
|
departureTime: new Date(departureTime),
|
||||||
|
arrivalTime: new Date(arrivalTime),
|
||||||
|
vehicleType,
|
||||||
|
capacity: Number(capacity),
|
||||||
|
price: Number(price),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
schedule: {
|
||||||
|
...schedule,
|
||||||
|
price: Number(schedule.price),
|
||||||
|
route: {
|
||||||
|
...schedule.route,
|
||||||
|
basePrice: Number(schedule.route.basePrice),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to create schedule' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const settings = await getSettings();
|
||||||
|
return NextResponse.json({ settings });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
'brandName',
|
||||||
|
'logoIcon',
|
||||||
|
'logoHighlight',
|
||||||
|
'primaryColor',
|
||||||
|
'csPhone',
|
||||||
|
'csWhatsapp',
|
||||||
|
'csEmail',
|
||||||
|
'pakasirSlug',
|
||||||
|
'pakasirApiKey',
|
||||||
|
'smtpHost',
|
||||||
|
'smtpPort',
|
||||||
|
'smtpUser',
|
||||||
|
'smtpPassword',
|
||||||
|
'smtpSenderName',
|
||||||
|
'smtpSenderEmail',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = formData.get(key);
|
||||||
|
if (val !== null) {
|
||||||
|
await prisma.systemSetting.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value: String(val) },
|
||||||
|
create: { key, value: String(val) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle deleteLogoImage action
|
||||||
|
const deleteLogoImage = formData.get('deleteLogoImage');
|
||||||
|
if (deleteLogoImage === 'true') {
|
||||||
|
await prisma.systemSetting.upsert({
|
||||||
|
where: { key: 'logoImageUrl' },
|
||||||
|
update: { value: '' },
|
||||||
|
create: { key: 'logoImageUrl', value: '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logo file upload
|
||||||
|
const logoFile = formData.get('logoFile') as File | null;
|
||||||
|
if (logoFile && logoFile.size > 0) {
|
||||||
|
const bytes = await logoFile.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
|
||||||
|
const uploadDir = path.join(process.cwd(), 'public', 'uploads');
|
||||||
|
// Create folder recursively if it doesn't exist
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
|
||||||
|
const filename = `logo-${Date.now()}${path.extname(logoFile.name)}`;
|
||||||
|
const filePath = path.join(uploadDir, filename);
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, buffer);
|
||||||
|
|
||||||
|
const logoUrl = `/uploads/${filename}`;
|
||||||
|
await prisma.systemSetting.upsert({
|
||||||
|
where: { key: 'logoImageUrl' },
|
||||||
|
update: { value: logoUrl },
|
||||||
|
create: { key: 'logoImageUrl', value: logoUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
console.error('Update settings error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// 1. Authorize Admin
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
// 2. Fetch statistics
|
||||||
|
const paidBookings = await prisma.booking.findMany({
|
||||||
|
where: { status: 'PAID' },
|
||||||
|
select: { totalPrice: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRevenue = paidBookings.reduce((sum, b) => sum + Number(b.totalPrice), 0);
|
||||||
|
const paidBookingsCount = paidBookings.length;
|
||||||
|
|
||||||
|
const pendingBookingsCount = await prisma.booking.count({
|
||||||
|
where: { status: 'PENDING' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const routesCount = await prisma.route.count();
|
||||||
|
const schedulesCount = await prisma.schedule.count();
|
||||||
|
|
||||||
|
// 3. Fetch recent bookings
|
||||||
|
const recentBookings = await prisma.booking.findMany({
|
||||||
|
take: 10,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedRecentBookings = recentBookings.map((b) => ({
|
||||||
|
...b,
|
||||||
|
totalPrice: Number(b.totalPrice),
|
||||||
|
schedule: {
|
||||||
|
...b.schedule,
|
||||||
|
price: Number(b.schedule.price),
|
||||||
|
route: {
|
||||||
|
...b.schedule.route,
|
||||||
|
basePrice: Number(b.schedule.route.basePrice),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
stats: {
|
||||||
|
totalRevenue,
|
||||||
|
paidBookingsCount,
|
||||||
|
pendingBookingsCount,
|
||||||
|
routesCount,
|
||||||
|
schedulesCount,
|
||||||
|
},
|
||||||
|
recentBookings: normalizedRecentBookings,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Admin stats error:', error);
|
||||||
|
if (error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch admin stats' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
let layouts = [];
|
||||||
|
if (settings.vehicleLayouts) {
|
||||||
|
layouts = JSON.parse(settings.vehicleLayouts);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ layouts });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch vehicle layouts error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch layouts' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
await requireAdmin();
|
||||||
|
const body = await request.json();
|
||||||
|
const { layouts } = body;
|
||||||
|
|
||||||
|
if (!layouts || !Array.isArray(layouts)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid layouts payload' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.systemSetting.upsert({
|
||||||
|
where: { key: 'vehicleLayouts' },
|
||||||
|
update: { value: JSON.stringify(layouts) },
|
||||||
|
create: { key: 'vehicleLayouts', value: JSON.stringify(layouts) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Unauthorized') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error('Save vehicle layouts error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to save layouts' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { signJWT } from '@/lib/auth';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { email, password } = await request.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email and password are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid email or password' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await signJWT({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set JWT token in an httpOnly cookie
|
||||||
|
response.cookies.set('auth_token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24, // 1 day
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Something went wrong. Please try again.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const response = NextResponse.json({ success: true });
|
||||||
|
response.cookies.set('auth_token', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
expires: new Date(0),
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { signJWT } from '@/lib/auth';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { email, password, name, phone } = await request.json();
|
||||||
|
|
||||||
|
if (!email || !password || !name || !phone) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'All fields are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Email is already registered' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
role: 'USER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await signJWT({
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
name: user.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
response.cookies.set('auth_token', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 60 * 60 * 24, // 1 day
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Something went wrong. Please try again.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSessionUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ user: null }, { status: 401 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ user: null }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
import { sendTicketEmail } from '@/lib/email';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { code: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const bookingCode = params.code;
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return NextResponse.json({ error: 'Pemesanan tidak ditemukan.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.paymentMethod !== 'QRIS') {
|
||||||
|
return NextResponse.json({ success: true, status: booking.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call Pakasir API
|
||||||
|
const settings = await getSettings();
|
||||||
|
const slug = settings.pakasirSlug;
|
||||||
|
const apiKey = settings.pakasirApiKey;
|
||||||
|
const amount = Math.round(Number(booking.totalPrice));
|
||||||
|
|
||||||
|
if (!slug || !apiKey) {
|
||||||
|
// If Pakasir is not fully configured, return current DB status
|
||||||
|
return NextResponse.json({ success: true, status: booking.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pakasirUrl = `https://app.pakasir.com/api/transactiondetail?project=${slug}&amount=${amount}&order_id=${bookingCode}&api_key=${apiKey}`;
|
||||||
|
|
||||||
|
const res = await fetch(pakasirUrl, { cache: 'no-store' });
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error('Failed to fetch transaction status from Pakasir:', await res.text());
|
||||||
|
return NextResponse.json({ success: true, status: booking.status }); // Fail silently, return current db status
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
console.log(data);
|
||||||
|
if (data.transaction && data.transaction.status === 'completed') {
|
||||||
|
// If status in local DB is not PAID yet, update it and send ticket email
|
||||||
|
if (booking.status !== 'PAID') {
|
||||||
|
const updated = await prisma.booking.update({
|
||||||
|
where: { bookingCode },
|
||||||
|
data: {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentTime: data.transaction.completed_at ? new Date(data.transaction.completed_at) : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email receipt automatically
|
||||||
|
await sendTicketEmail(booking.id);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, status: 'PAID', booking: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, status: 'PAID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, status: booking.status });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Check Pakasir status error:', error);
|
||||||
|
return NextResponse.json({ error: 'Gagal mengecek status pembayaran Pakasir.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { code: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const bookingCode = params.code;
|
||||||
|
const body = await request.json();
|
||||||
|
const { paymentMethod } = body;
|
||||||
|
|
||||||
|
if (!paymentMethod || !['TUNAI', 'QRIS'].includes(paymentMethod)) {
|
||||||
|
return NextResponse.json({ error: 'Metode pembayaran tidak valid.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return NextResponse.json({ error: 'Pemesanan tidak ditemukan.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'PENDING') {
|
||||||
|
return NextResponse.json({ error: 'Pemesanan ini sudah lunas atau dibatalkan.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentMethod === 'TUNAI') {
|
||||||
|
const updated = await prisma.booking.update({
|
||||||
|
where: { bookingCode },
|
||||||
|
data: {
|
||||||
|
paymentMethod: 'TUNAI',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return NextResponse.json({ success: true, booking: updated });
|
||||||
|
} else {
|
||||||
|
// QRIS via Pakasir
|
||||||
|
const settings = await getSettings();
|
||||||
|
const slug = settings.pakasirSlug;
|
||||||
|
const apiKey = settings.pakasirApiKey;
|
||||||
|
|
||||||
|
if (!slug || !apiKey) {
|
||||||
|
return NextResponse.json({ error: 'Pembayaran QRIS belum dikonfigurasi lengkap oleh Admin.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record selection in DB
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { bookingCode },
|
||||||
|
data: {
|
||||||
|
paymentMethod: 'QRIS',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Process payment error:', error);
|
||||||
|
return NextResponse.json({ error: 'Gagal memproses metode pembayaran.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { code: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const bookingCode = params.code;
|
||||||
|
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return NextResponse.json({ error: 'Pemesanan tidak ditemukan.' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
const slug = settings.pakasirSlug;
|
||||||
|
const apiKey = settings.pakasirApiKey;
|
||||||
|
|
||||||
|
if (!slug || !apiKey) {
|
||||||
|
return NextResponse.json({ error: 'Integrasi Pakasir belum dikonfigurasi lengkap oleh Admin.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = Math.round(Number(booking.totalPrice));
|
||||||
|
|
||||||
|
// Call Pakasir direct transaction create API
|
||||||
|
const res = await fetch('https://app.pakasir.com/api/transactioncreate/qris', {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
project: slug,
|
||||||
|
order_id: bookingCode,
|
||||||
|
amount: amount,
|
||||||
|
api_key: apiKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorText = await res.text();
|
||||||
|
console.error('Pakasir API Error:', errorText);
|
||||||
|
return NextResponse.json({ error: 'Gagal membuat QRIS ke penyedia pembayaran.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const paymentNumber = data.payment?.payment_number;
|
||||||
|
|
||||||
|
if (!paymentNumber) {
|
||||||
|
return NextResponse.json({ error: 'Nomor/String QRIS tidak ditemukan dari response penyedia.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, qrisString: paymentNumber });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('QRIS Generation Error:', error);
|
||||||
|
return NextResponse.json({ error: 'Terjadi kesalahan internal server.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { code: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const bookingCode = params.code;
|
||||||
|
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode },
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return NextResponse.json({ error: 'Booking not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Decimals
|
||||||
|
const normalizedBooking = {
|
||||||
|
...booking,
|
||||||
|
totalPrice: Number(booking.totalPrice),
|
||||||
|
schedule: {
|
||||||
|
...booking.schedule,
|
||||||
|
price: Number(booking.schedule.price),
|
||||||
|
route: {
|
||||||
|
...booking.schedule.route,
|
||||||
|
basePrice: Number(booking.schedule.route.basePrice),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({ booking: normalizedBooking });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch booking error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch booking details' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSessionUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const {
|
||||||
|
scheduleId,
|
||||||
|
passengerName,
|
||||||
|
passengerEmail,
|
||||||
|
passengerPhone,
|
||||||
|
seats, // Array of strings, e.g. ["1", "2"] or ["1A", "1B"]
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!scheduleId || !passengerName || !passengerEmail || !passengerPhone || !seats || !Array.isArray(seats) || seats.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required booking details or seat selections' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch schedule to get the price
|
||||||
|
const schedule = await prisma.schedule.findUnique({
|
||||||
|
where: { id: Number(scheduleId) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
return NextResponse.json({ error: 'Schedule not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check optional user session link
|
||||||
|
const userSession = await getSessionUser();
|
||||||
|
const userId = userSession ? userSession.userId : null;
|
||||||
|
|
||||||
|
// Calculate total price
|
||||||
|
const unitPrice = Number(schedule.price);
|
||||||
|
const totalPrice = unitPrice * seats.length;
|
||||||
|
|
||||||
|
// Generate unique booking code
|
||||||
|
const bookingCode = 'TRV-' + Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
|
||||||
|
// Perform database transaction to ensure atomic operations (no double seat bookings)
|
||||||
|
const booking = await prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Check if any selected seat is already occupied for this schedule
|
||||||
|
const occupied = await tx.bookingSeat.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId: Number(scheduleId),
|
||||||
|
seatNumber: { in: seats },
|
||||||
|
booking: {
|
||||||
|
status: { in: ['PENDING', 'PAID'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (occupied.length > 0) {
|
||||||
|
throw new Error('One or more selected seats have already been booked. Please choose other seats.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create the booking record
|
||||||
|
const newBooking = await tx.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingCode,
|
||||||
|
scheduleId: Number(scheduleId),
|
||||||
|
userId,
|
||||||
|
passengerName,
|
||||||
|
passengerEmail,
|
||||||
|
passengerPhone,
|
||||||
|
totalPrice,
|
||||||
|
status: 'PENDING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Create the booking seat records
|
||||||
|
const seatRecords = seats.map((seat: string) => ({
|
||||||
|
bookingId: newBooking.id,
|
||||||
|
scheduleId: Number(scheduleId),
|
||||||
|
seatNumber: seat,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await tx.bookingSeat.createMany({
|
||||||
|
data: seatRecords,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newBooking;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, bookingCode: booking.bookingCode, bookingId: booking.id });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Create booking transaction error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to process booking. Please try again.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const routes = await prisma.route.findMany({
|
||||||
|
orderBy: [
|
||||||
|
{ departureCity: 'asc' },
|
||||||
|
{ arrivalCity: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return NextResponse.json({ routes });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch routes error:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch routes' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const departureCity = searchParams.get('from');
|
||||||
|
const arrivalCity = searchParams.get('to');
|
||||||
|
const dateStr = searchParams.get('date');
|
||||||
|
|
||||||
|
if (!departureCity || !arrivalCity || !dateStr) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing parameters from, to, or date' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOfDay = new Date(`${dateStr}T00:00:00.000Z`);
|
||||||
|
const endOfDay = new Date(`${dateStr}T23:59:59.999Z`);
|
||||||
|
|
||||||
|
const schedules = await prisma.schedule.findMany({
|
||||||
|
where: {
|
||||||
|
route: {
|
||||||
|
departureCity,
|
||||||
|
arrivalCity,
|
||||||
|
},
|
||||||
|
departureTime: {
|
||||||
|
gte: startOfDay,
|
||||||
|
lte: endOfDay,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
status: { in: ['PENDING', 'PAID'] },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
departureTime: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = schedules.map((schedule) => {
|
||||||
|
const occupiedSeats = schedule.bookings.flatMap((b) =>
|
||||||
|
b.seats.map((s) => s.seatNumber)
|
||||||
|
);
|
||||||
|
const availableSeatsCount = schedule.capacity - occupiedSeats.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: schedule.id,
|
||||||
|
routeId: schedule.routeId,
|
||||||
|
departureTime: schedule.departureTime,
|
||||||
|
arrivalTime: schedule.arrivalTime,
|
||||||
|
vehicleType: schedule.vehicleType,
|
||||||
|
capacity: schedule.capacity,
|
||||||
|
price: Number(schedule.price),
|
||||||
|
departureCity: schedule.route.departureCity,
|
||||||
|
arrivalCity: schedule.route.arrivalCity,
|
||||||
|
durationMinutes: schedule.route.durationMinutes,
|
||||||
|
occupiedSeats,
|
||||||
|
availableSeatsCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ schedules: results });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search schedules error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to search schedules' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const settings = await getSettings();
|
||||||
|
// Exclude private keys from public endpoint
|
||||||
|
const publicSettings = {
|
||||||
|
brandName: settings.brandName,
|
||||||
|
logoIcon: settings.logoIcon,
|
||||||
|
logoHighlight: settings.logoHighlight,
|
||||||
|
logoImageUrl: settings.logoImageUrl,
|
||||||
|
primaryColor: settings.primaryColor,
|
||||||
|
csPhone: settings.csPhone,
|
||||||
|
csWhatsapp: settings.csWhatsapp,
|
||||||
|
csEmail: settings.csEmail,
|
||||||
|
vehicleLayouts: settings.vehicleLayouts,
|
||||||
|
};
|
||||||
|
return NextResponse.json({ settings: publicSettings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get public settings:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSessionUser } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
userId: user.userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializedBookings = bookings.map((booking) => ({
|
||||||
|
id: booking.id,
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
passengerName: booking.passengerName,
|
||||||
|
passengerEmail: booking.passengerEmail,
|
||||||
|
passengerPhone: booking.passengerPhone,
|
||||||
|
totalPrice: Number(booking.totalPrice),
|
||||||
|
status: booking.status,
|
||||||
|
paymentMethod: booking.paymentMethod,
|
||||||
|
paymentTime: booking.paymentTime ? booking.paymentTime.toISOString() : null,
|
||||||
|
createdAt: booking.createdAt.toISOString(),
|
||||||
|
seats: booking.seats.map((s) => s.seatNumber),
|
||||||
|
schedule: {
|
||||||
|
departureTime: booking.schedule.departureTime.toISOString(),
|
||||||
|
arrivalTime: booking.schedule.arrivalTime.toISOString(),
|
||||||
|
vehicleType: booking.schedule.vehicleType,
|
||||||
|
departureCity: booking.schedule.route.departureCity,
|
||||||
|
arrivalCity: booking.schedule.route.arrivalCity,
|
||||||
|
durationMinutes: booking.schedule.route.durationMinutes,
|
||||||
|
price: Number(booking.schedule.price),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ bookings: serializedBookings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch user bookings error:', error);
|
||||||
|
return NextResponse.json({ error: 'Gagal mengambil data pemesanan.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
import { sendTicketEmail } from '@/lib/email';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { amount, order_id, project, status, payment_method, completed_at } = body;
|
||||||
|
|
||||||
|
// Validate request body
|
||||||
|
if (!order_id || !amount || !status) {
|
||||||
|
return NextResponse.json({ error: 'Payload tidak valid' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify project matches configured slug
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (project !== settings.pakasirSlug) {
|
||||||
|
return NextResponse.json({ error: 'Slug proyek tidak cocok' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find booking
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode: order_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
return NextResponse.json({ error: 'Pemesanan tidak ditemukan' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify amount matches
|
||||||
|
const expectedAmount = Math.round(Number(booking.totalPrice));
|
||||||
|
if (expectedAmount !== Math.round(Number(amount))) {
|
||||||
|
return NextResponse.json({ error: 'Nominal pembayaran tidak sesuai' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
await prisma.booking.update({
|
||||||
|
where: { bookingCode: order_id },
|
||||||
|
data: {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentMethod: 'QRIS',
|
||||||
|
paymentTime: completed_at ? new Date(completed_at) : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Webhook: Booking ${order_id} lunas via Pakasir.`);
|
||||||
|
|
||||||
|
// Send receipt email
|
||||||
|
await sendTicketEmail(booking.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Webhook error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import BookingForm from '@/components/BookingForm';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function BookingPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: { scheduleId: string };
|
||||||
|
searchParams: { passengers?: string };
|
||||||
|
}) {
|
||||||
|
const scheduleId = Number(params.scheduleId);
|
||||||
|
const passengersCount = Number(searchParams.passengers || '1');
|
||||||
|
|
||||||
|
// Fetch schedule with route and occupied seats
|
||||||
|
const schedule = await prisma.schedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
status: { in: ['PENDING', 'PAID'] },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get occupied seats list
|
||||||
|
const occupiedSeats = schedule.bookings.flatMap((b) =>
|
||||||
|
b.seats.map((s) => s.seatNumber)
|
||||||
|
);
|
||||||
|
|
||||||
|
const serializedSchedule = {
|
||||||
|
id: schedule.id,
|
||||||
|
departureTime: schedule.departureTime.toISOString(),
|
||||||
|
arrivalTime: schedule.arrivalTime.toISOString(),
|
||||||
|
vehicleType: schedule.vehicleType,
|
||||||
|
capacity: schedule.capacity,
|
||||||
|
price: Number(schedule.price),
|
||||||
|
departureCity: schedule.route.departureCity,
|
||||||
|
arrivalCity: schedule.route.arrivalCity,
|
||||||
|
durationMinutes: schedule.route.durationMinutes,
|
||||||
|
occupiedSeats,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BookingForm
|
||||||
|
schedule={serializedSchedule}
|
||||||
|
passengersCount={passengersCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import CheckoutClient from '@/components/CheckoutClient';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function CheckoutPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { code: string };
|
||||||
|
}) {
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode: params.code },
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
const serializedBooking = {
|
||||||
|
id: booking.id,
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
passengerName: booking.passengerName,
|
||||||
|
passengerEmail: booking.passengerEmail,
|
||||||
|
passengerPhone: booking.passengerPhone,
|
||||||
|
totalPrice: Number(booking.totalPrice),
|
||||||
|
status: booking.status,
|
||||||
|
paymentMethod: booking.paymentMethod,
|
||||||
|
paymentTime: booking.paymentTime ? booking.paymentTime.toISOString() : null,
|
||||||
|
createdAt: booking.createdAt.toISOString(),
|
||||||
|
seats: booking.seats.map((s) => s.seatNumber),
|
||||||
|
schedule: {
|
||||||
|
id: booking.schedule.id,
|
||||||
|
departureTime: booking.schedule.departureTime.toISOString(),
|
||||||
|
arrivalTime: booking.schedule.arrivalTime.toISOString(),
|
||||||
|
vehicleType: booking.schedule.vehicleType,
|
||||||
|
price: Number(booking.schedule.price),
|
||||||
|
departureCity: booking.schedule.route.departureCity,
|
||||||
|
arrivalCity: booking.schedule.route.arrivalCity,
|
||||||
|
durationMinutes: booking.schedule.route.durationMinutes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <CheckoutClient booking={serializedBooking} settings={settings} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcomeTitle {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcomeSubtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookingsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookingCard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 30px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookingCard:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.bookingCard {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardMain {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cityGroup {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-top: 2px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailValue {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsBadge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-white);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardSidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
border-left: 1px dashed var(--border-light);
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cardSidebar {
|
||||||
|
border-left: none;
|
||||||
|
padding-left: 0;
|
||||||
|
border-top: 1px dashed var(--border-light);
|
||||||
|
padding-top: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeValue {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyBtn:hover {
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceValue {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgePaid {
|
||||||
|
background: color-mix(in srgb, var(--accent-emerald) 10%, transparent);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-emerald) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgePending {
|
||||||
|
background: color-mix(in srgb, var(--accent-amber) 10%, transparent);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-amber) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeCancelled {
|
||||||
|
background: color-mix(in srgb, var(--accent-rose) 10%, transparent);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-rose) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardActions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cardActions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState h3 {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive dashboard */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statsGrid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statCard {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statValue {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeInfo {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.welcomeTitle {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcomeSubtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeValue {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceValue {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaTicketAlt, FaArrowRight, FaClipboard, FaCheckCircle, FaCreditCard } from 'react-icons/fa';
|
||||||
|
import styles from './dashboard.module.css';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: number;
|
||||||
|
bookingCode: string;
|
||||||
|
passengerName: string;
|
||||||
|
passengerEmail: string;
|
||||||
|
passengerPhone: string;
|
||||||
|
totalPrice: number;
|
||||||
|
status: string;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
paymentTime: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
seats: string[];
|
||||||
|
schedule: {
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSession {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [user, setUser] = useState<UserSession | null>(null);
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function initDashboard() {
|
||||||
|
try {
|
||||||
|
// 1. Verify User Session
|
||||||
|
const sessionRes = await fetch('/api/auth/session');
|
||||||
|
if (!sessionRes.ok) {
|
||||||
|
router.push('/login?redirect=/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sessionData = await sessionRes.json();
|
||||||
|
setUser(sessionData.user);
|
||||||
|
|
||||||
|
// 2. Fetch User Bookings
|
||||||
|
const bookingsRes = await fetch('/api/user/bookings');
|
||||||
|
if (bookingsRes.ok) {
|
||||||
|
const bookingsData = await bookingsRes.json();
|
||||||
|
setBookings(bookingsData.bookings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading dashboard data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initDashboard();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleCopyCode = (code: string) => {
|
||||||
|
navigator.clipboard.writeText(code);
|
||||||
|
setCopiedCode(code);
|
||||||
|
setTimeout(() => setCopiedCode(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (val: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (isoString: string) => {
|
||||||
|
return new Date(isoString).toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (isoString: string) => {
|
||||||
|
return new Date(isoString).toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat dashboard Anda...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
// Stats derivations
|
||||||
|
const totalBookings = bookings.length;
|
||||||
|
const activeTickets = bookings.filter((b) => b.status === 'PAID').length;
|
||||||
|
const pendingPayments = bookings.filter((b) => b.status === 'PENDING').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.welcomeTitle}>Halo, {user.name}!</h1>
|
||||||
|
<p className={styles.welcomeSubtitle}>Kelola pesanan dan tiket perjalanan Anda di sini.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Summary */}
|
||||||
|
<div className={styles.statsGrid}>
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<span className={styles.statLabel}>Total Pemesanan</span>
|
||||||
|
<span className={styles.statValue}>{totalBookings}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<span className={styles.statLabel}>Tiket Aktif (Lunas)</span>
|
||||||
|
<span className={styles.statValue} style={{ color: 'var(--accent-emerald)' }}>{activeTickets}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.statCard} glass-panel`}>
|
||||||
|
<span className={styles.statLabel}>Menunggu Pembayaran</span>
|
||||||
|
<span className={styles.statValue} style={{ color: 'var(--accent-amber)' }}>{pendingPayments}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bookings Section */}
|
||||||
|
<div>
|
||||||
|
<h2 className={styles.sectionTitle}>Daftar Riwayat Perjalanan</h2>
|
||||||
|
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className={`${styles.emptyState} glass-panel`} style={{ marginTop: '20px' }}>
|
||||||
|
<span className={styles.emptyIcon}><FaTicketAlt /></span>
|
||||||
|
<h3>Belum Ada Pesanan</h3>
|
||||||
|
<p>Anda belum melakukan pemesanan tiket perjalanan apa pun saat ini.</p>
|
||||||
|
<Link href="/" className="btn btn-primary">
|
||||||
|
Cari & Pesan Tiket Sekarang
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.bookingsList} style={{ marginTop: '20px' }}>
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<div key={booking.id} className={`${styles.bookingCard} glass-panel`}>
|
||||||
|
{/* Main Details */}
|
||||||
|
<div className={styles.cardMain}>
|
||||||
|
<div className={styles.routeInfo}>
|
||||||
|
<div className={styles.cityGroup}>
|
||||||
|
<span className={styles.city}>{booking.schedule.departureCity}</span>
|
||||||
|
<span className={styles.time}>{formatTime(booking.schedule.departureTime)}</span>
|
||||||
|
</div>
|
||||||
|
<span className={styles.arrow}><FaArrowRight /></span>
|
||||||
|
<div className={styles.cityGroup}>
|
||||||
|
<span className={styles.city}>{booking.schedule.arrivalCity}</span>
|
||||||
|
<span className={styles.time}>{formatTime(booking.schedule.arrivalTime)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailsGrid}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.detailLabel}>Tanggal Perjalanan</span>
|
||||||
|
<span className={styles.detailValue}>{formatDate(booking.schedule.departureTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.detailLabel}>Jenis Armada</span>
|
||||||
|
<span className={styles.detailValue}>{booking.schedule.vehicleType}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.detailLabel}>Kursi Terpilih</span>
|
||||||
|
<div style={{ marginTop: '4px' }}>
|
||||||
|
{booking.seats.map((seat) => (
|
||||||
|
<span key={seat} className={styles.seatsBadge} style={{ marginRight: '6px' }}>
|
||||||
|
{seat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.detailLabel}>Nama Penumpang</span>
|
||||||
|
<span className={styles.detailValue}>{booking.passengerName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar Details & Actions */}
|
||||||
|
<div className={styles.cardSidebar}>
|
||||||
|
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
|
<div className={styles.codeGroup}>
|
||||||
|
<span className={styles.codeLabel}>Kode Booking</span>
|
||||||
|
<div className={styles.codeRow}>
|
||||||
|
<span className={styles.codeValue}>{booking.bookingCode}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyCode(booking.bookingCode)}
|
||||||
|
className={styles.copyBtn}
|
||||||
|
title="Salin Kode Booking"
|
||||||
|
>
|
||||||
|
{copiedCode === booking.bookingCode ? <FaCheckCircle /> : <FaClipboard />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span className={styles.priceValue}>{formatCurrency(booking.totalPrice)}</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.badge} ${
|
||||||
|
booking.status === 'PAID'
|
||||||
|
? styles.badgePaid
|
||||||
|
: booking.status === 'PENDING'
|
||||||
|
? styles.badgePending
|
||||||
|
: styles.badgeCancelled
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{booking.status === 'PAID' ? 'LUNAS' : booking.status === 'PENDING' ? 'PENDING' : 'BATAL'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contextual Action Button */}
|
||||||
|
<div className={styles.cardActions}>
|
||||||
|
{booking.status === 'PENDING' && (
|
||||||
|
<Link
|
||||||
|
href={`/booking/checkout/${booking.bookingCode}`}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%', textAlign: 'center', padding: '8px 16px', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
<FaCreditCard style={{ marginRight: '6px' }} /> Selesaikan Pembayaran
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{booking.status === 'PAID' && (
|
||||||
|
<Link
|
||||||
|
href={`/ticket/${booking.bookingCode}`}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ width: '100%', textAlign: 'center', padding: '8px 16px', fontSize: '0.85rem' }}
|
||||||
|
>
|
||||||
|
<FaTicketAlt style={{ marginRight: '6px' }} /> Lihat Tiket Digital
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+254
-23
@@ -1,33 +1,66 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--font-inter: 'Inter', sans-serif;
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Color Palette - Premium Dynamic Dark Theme */
|
||||||
:root {
|
--bg-dark: color-mix(in srgb, var(--primary) 4%, #04050a);
|
||||||
--background: #0a0a0a;
|
--bg-deep: color-mix(in srgb, var(--primary) 7%, #020306);
|
||||||
--foreground: #ededed;
|
--bg-card: color-mix(in srgb, var(--primary) 10%, rgba(10, 15, 30, 0.65));
|
||||||
}
|
--bg-card-hover: color-mix(in srgb, var(--primary) 15%, rgba(15, 22, 45, 0.85));
|
||||||
}
|
--border-light: color-mix(in srgb, var(--primary) 15%, rgba(255, 255, 255, 0.05));
|
||||||
|
--border-focus: color-mix(in srgb, var(--primary) 50%, transparent);
|
||||||
|
|
||||||
html,
|
--text-white: #ffffff;
|
||||||
body {
|
--text-primary: #f3f4f6;
|
||||||
max-width: 100vw;
|
--text-secondary: color-mix(in srgb, var(--primary) 15%, #9ca3af);
|
||||||
overflow-x: hidden;
|
--text-muted: color-mix(in srgb, var(--primary) 10%, #6b7280);
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
--primary: #6366f1;
|
||||||
color: var(--foreground);
|
/* Indigo-500 */
|
||||||
background: var(--background);
|
--primary-hover: color-mix(in srgb, var(--primary) 85%, black);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
--primary-glow: color-mix(in srgb, var(--primary) 25%, transparent);
|
||||||
-webkit-font-smoothing: antialiased;
|
--primary-text: var(--primary);
|
||||||
-moz-osx-font-smoothing: grayscale;
|
--text-on-primary: #ffffff;
|
||||||
|
|
||||||
|
--accent-emerald: #10b981;
|
||||||
|
/* Success / Paid */
|
||||||
|
--accent-amber: #f59e0b;
|
||||||
|
/* Pending */
|
||||||
|
--accent-rose: #f43f5e;
|
||||||
|
/* Cancelled / Occupied */
|
||||||
|
|
||||||
|
--shadow-lg: 0 10px 30px -10px rgba(0, 0, 0, 0.7);
|
||||||
|
--shadow-glow: 0 0 20px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
|
||||||
|
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Fallback dark theme variables for nested layouts */
|
||||||
|
--bg-input: color-mix(in srgb, var(--primary) 6%, rgba(10, 15, 30, 0.8));
|
||||||
|
--bg-header: color-mix(in srgb, var(--primary) 7%, rgba(4, 5, 10, 0.7));
|
||||||
|
--bg-sidebar: color-mix(in srgb, var(--primary) 7%, rgba(4, 5, 10, 0.95));
|
||||||
|
--bg-topheader: color-mix(in srgb, var(--primary) 4%, rgba(4, 5, 10, 0.4));
|
||||||
|
--bg-navlink-hover: rgba(255, 255, 255, 0.05);
|
||||||
|
--bg-table-row-hover: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--font-inter);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-dark);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 0% 0%, color-mix(in srgb, var(--primary) 12%, transparent) 0px, transparent 55%),
|
||||||
|
radial-gradient(at 100% 100%, color-mix(in srgb, var(--primary) 8%, transparent) 0px, transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -35,8 +68,206 @@ a {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Glassmorphism Containers */
|
||||||
html {
|
.glass-panel {
|
||||||
color-scheme: dark;
|
background: var(--bg-card);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel:hover {
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms & Inputs */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
outline: none;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
border-color: var(--text-white);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-on-primary);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 0 25px color-mix(in srgb, var(--primary) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--accent-rose);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #e11d48;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: var(--accent-emerald);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #059669;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge / Pills */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-paid {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-cancelled {
|
||||||
|
background: rgba(244, 63, 94, 0.15);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout helpers */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styles */
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr {
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: var(--bg-table-row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global Print Styles */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
nav,
|
||||||
|
aside {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-21
@@ -1,33 +1,110 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from 'next';
|
||||||
import localFont from "next/font/local";
|
import './globals.css';
|
||||||
import "./globals.css";
|
import Header from '@/components/Header';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
const geistSans = localFont({
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
src: "./fonts/GeistVF.woff",
|
const settings = await getSettings();
|
||||||
variable: "--font-geist-sans",
|
return {
|
||||||
weight: "100 900",
|
title: `${settings.brandName} - Pemesanan Tiket Bis & Shuttle`,
|
||||||
});
|
description: `Pesan tiket bis dan shuttle antarkota dengan mudah, cepat, dan aman di ${settings.brandName}.`,
|
||||||
const geistMono = localFont({
|
};
|
||||||
src: "./fonts/GeistMonoVF.woff",
|
}
|
||||||
variable: "--font-geist-mono",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
function isLightColor(hexColor: string): boolean {
|
||||||
title: "Create Next App",
|
const hex = hexColor.replace('#', '');
|
||||||
description: "Generated by create next app",
|
if (hex.length !== 6) return false;
|
||||||
};
|
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Calculate brightness using standard relative luminance formula
|
||||||
|
const brightness = Math.sqrt(
|
||||||
|
0.299 * (r * r) +
|
||||||
|
0.587 * (g * g) +
|
||||||
|
0.114 * (b * b)
|
||||||
|
);
|
||||||
|
|
||||||
|
return brightness > 140; // True if closer to a light color
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
function isVeryLightColor(hexColor: string): boolean {
|
||||||
|
const hex = hexColor.replace('#', '');
|
||||||
|
if (hex.length !== 6) return false;
|
||||||
|
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
const brightness = Math.sqrt(
|
||||||
|
0.299 * (r * r) +
|
||||||
|
0.587 * (g * g) +
|
||||||
|
0.114 * (b * b)
|
||||||
|
);
|
||||||
|
|
||||||
|
return brightness > 190;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const primaryColor = settings.primaryColor || '#6366f1';
|
||||||
|
const isLight = isLightColor(primaryColor);
|
||||||
|
const isVeryLight = isVeryLightColor(primaryColor);
|
||||||
|
const textOnPrimary = isVeryLight ? '#1e293b' : '#ffffff';
|
||||||
|
|
||||||
|
const themeVariables = isLight ? `
|
||||||
|
:root {
|
||||||
|
--primary: ${primaryColor};
|
||||||
|
--primary-text: color-mix(in srgb, var(--primary) 70%, #000000);
|
||||||
|
--text-on-primary: ${textOnPrimary};
|
||||||
|
--bg-dark: color-mix(in srgb, var(--primary) 3%, #f8fafc);
|
||||||
|
--bg-deep: color-mix(in srgb, var(--primary) 5%, #f1f5f9);
|
||||||
|
--bg-card: color-mix(in srgb, var(--primary) 5%, rgba(255, 255, 255, 0.85));
|
||||||
|
--bg-card-hover: color-mix(in srgb, var(--primary) 8%, rgba(255, 255, 255, 0.95));
|
||||||
|
--border-light: color-mix(in srgb, var(--primary) 12%, rgba(0, 0, 0, 0.08));
|
||||||
|
--border-focus: color-mix(in srgb, var(--primary) 50%, rgba(0, 0, 0, 0.15));
|
||||||
|
|
||||||
|
--text-white: #1e293b;
|
||||||
|
--text-primary: #334155;
|
||||||
|
--text-secondary: #475569;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
|
||||||
|
--bg-input: color-mix(in srgb, var(--primary) 4%, rgba(255, 255, 255, 0.95));
|
||||||
|
--bg-header: color-mix(in srgb, var(--primary) 5%, rgba(255, 255, 255, 0.8));
|
||||||
|
--bg-sidebar: color-mix(in srgb, var(--primary) 5%, rgba(255, 255, 255, 0.98));
|
||||||
|
--bg-topheader: color-mix(in srgb, var(--primary) 3%, rgba(255, 255, 255, 0.5));
|
||||||
|
--bg-navlink-hover: rgba(0, 0, 0, 0.04);
|
||||||
|
--bg-table-row-hover: rgba(0, 0, 0, 0.02);
|
||||||
|
|
||||||
|
--shadow-lg: 0 10px 30px -10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
` : `
|
||||||
|
:root {
|
||||||
|
--primary: ${primaryColor};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="id">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<head>
|
||||||
{children}
|
<style dangerouslySetInnerHTML={{
|
||||||
|
__html: themeVariables
|
||||||
|
}} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Header settings={settings} />
|
||||||
|
<main style={{ minHeight: 'calc(100vh - 70px - 340px)' }}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer settings={settings} />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 70px - 340px);
|
||||||
|
padding: 40px 20px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 3rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerText {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoBox {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoBox h4 {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoBtn {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoBtn:hover {
|
||||||
|
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||||
|
color: var(--text-white);
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demoButtons {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaKey, FaUserCog, FaUser } from 'react-icons/fa';
|
||||||
|
import styles from './login.module.css';
|
||||||
|
|
||||||
|
function LoginContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const callback = searchParams.get('callback') || '';
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [brandName, setBrandName] = useState('AntarKota');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBrand() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setBrandName(data.settings.brandName);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBrand();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
// Check if already logged in, redirect to correct page
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/session');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.user?.role === 'ADMIN') {
|
||||||
|
router.push('/admin');
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Not logged in, stay on login page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkSession();
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!email || !password) {
|
||||||
|
setError('Silakan masukkan email dan password.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Email atau password salah.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful login, redirect to callback or home/admin depending on role
|
||||||
|
if (data.user.role === 'ADMIN') {
|
||||||
|
router.push(callback || '/admin');
|
||||||
|
} else {
|
||||||
|
router.push(callback || '/');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Gagal masuk. Silakan coba kembali.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={`${styles.card} glass-panel`}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.logoIcon}><FaKey /></span>
|
||||||
|
<h2>Masuk Akun</h2>
|
||||||
|
<p>Akses akun Anda untuk memesan tiket travel {brandName} dengan mudah.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className={styles.form}>
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Alamat Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: budi@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="Masukkan password Anda..."
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary" style={{ width: '100%', marginTop: '10px' }}>
|
||||||
|
{loading ? 'Masuk...' : 'Masuk Sekarang'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={styles.footerText}>
|
||||||
|
Belum punya akun? <Link href="/register" className={styles.link}>Daftar di sini</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={`${styles.card} glass-panel`}>
|
||||||
|
<p>Memuat halaman login...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<LoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
+83
-132
@@ -1,165 +1,116 @@
|
|||||||
.page {
|
.home {
|
||||||
--gray-rgb: 0, 0, 0;
|
padding-bottom: 60px;
|
||||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
|
|
||||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
|
|
||||||
|
|
||||||
--button-primary-hover: #383838;
|
|
||||||
--button-secondary-hover: #f2f2f2;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 20px 1fr 20px;
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
min-height: 100svh;
|
|
||||||
padding: 80px;
|
|
||||||
gap: 64px;
|
|
||||||
font-family: var(--font-geist-sans);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.hero {
|
||||||
.page {
|
padding: 100px 20px 80px 20px;
|
||||||
--gray-rgb: 255, 255, 255;
|
display: flex;
|
||||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
|
flex-direction: column;
|
||||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
--button-primary-hover: #ccc;
|
@media (max-width: 768px) {
|
||||||
--button-secondary-hover: #1a1a1a;
|
.hero {
|
||||||
|
padding: 50px 16px 40px 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.heroContent {
|
||||||
display: flex;
|
max-width: 700px;
|
||||||
flex-direction: column;
|
margin-bottom: 50px;
|
||||||
gap: 32px;
|
|
||||||
grid-row-start: 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main ol {
|
.heroTitle {
|
||||||
font-family: var(--font-geist-mono);
|
font-size: 3.5rem;
|
||||||
padding-left: 0;
|
font-weight: 800;
|
||||||
margin: 0;
|
line-height: 1.15;
|
||||||
font-size: 14px;
|
color: var(--text-white);
|
||||||
line-height: 24px;
|
margin-bottom: 20px;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.02em;
|
||||||
list-style-position: inside;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main li:not(:last-of-type) {
|
@media (max-width: 768px) {
|
||||||
margin-bottom: 8px;
|
.heroTitle {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main code {
|
.heroSubtitle {
|
||||||
font-family: inherit;
|
font-size: 1.15rem;
|
||||||
background: var(--gray-alpha-100);
|
color: var(--text-secondary);
|
||||||
padding: 2px 4px;
|
line-height: 1.6;
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctas {
|
.searchContainer {
|
||||||
display: flex;
|
width: 100%;
|
||||||
gap: 16px;
|
max-width: 1000px;
|
||||||
|
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctas a {
|
@keyframes slideUp {
|
||||||
appearance: none;
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
border-radius: 128px;
|
to { opacity: 1; transform: translateY(0); }
|
||||||
height: 48px;
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 80px auto 0 auto;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border: none;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a.primary {
|
.sectionTitle {
|
||||||
background: var(--foreground);
|
font-size: 1.75rem;
|
||||||
color: var(--background);
|
font-weight: 700;
|
||||||
gap: 8px;
|
color: var(--text-white);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
a.secondary {
|
.routesGrid {
|
||||||
border-color: var(--gray-alpha-200);
|
display: grid;
|
||||||
min-width: 180px;
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
grid-row-start: 3;
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer a {
|
.featuresGrid {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
gap: 8px;
|
gap: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer img {
|
.featureCard {
|
||||||
flex-shrink: 0;
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable hover only on non-touch devices */
|
.featureCard:hover {
|
||||||
@media (hover: hover) and (pointer: fine) {
|
background: rgba(255, 255, 255, 0.04);
|
||||||
a.primary:hover {
|
transform: translateY(-4px);
|
||||||
background: var(--button-primary-hover);
|
border-color: color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary:hover {
|
|
||||||
background: var(--button-secondary-hover);
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
.featureIcon {
|
||||||
.page {
|
font-size: 2.5rem;
|
||||||
padding: 32px;
|
display: inline-block;
|
||||||
padding-bottom: 80px;
|
margin-bottom: 20px;
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main ol {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ctas a {
|
|
||||||
font-size: 14px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.secondary {
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.featureCard h4 {
|
||||||
.logo {
|
font-size: 1.15rem;
|
||||||
filter: invert();
|
font-weight: 600;
|
||||||
}
|
color: var(--text-white);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.featureCard p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|||||||
+90
-88
@@ -1,95 +1,97 @@
|
|||||||
import Image from "next/image";
|
import { prisma } from '@/lib/db';
|
||||||
import styles from "./page.module.css";
|
import SearchWidget from '@/components/SearchWidget';
|
||||||
|
import { FaBuilding, FaMountain, FaTree, FaChair, FaBolt, FaShieldAlt } from 'react-icons/fa';
|
||||||
|
import styles from './page.module.css';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// Fetch active routes to populate search widget
|
||||||
|
let routes: any[] = [];
|
||||||
|
try {
|
||||||
|
routes = await prisma.route.findMany({
|
||||||
|
orderBy: { departureCity: 'asc' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load routes for search widget:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique departure and arrival cities
|
||||||
|
const departureCities = Array.from(new Set(routes.map(r => r.departureCity)));
|
||||||
|
const arrivalCities = Array.from(new Set(routes.map(r => r.arrivalCity)));
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.home}>
|
||||||
<main className={styles.main}>
|
{/* Hero Section */}
|
||||||
<Image
|
<section className={styles.hero}>
|
||||||
className={styles.logo}
|
<div className={styles.heroContent}>
|
||||||
src="https://nextjs.org/icons/next.svg"
|
<h1 className={styles.heroTitle}>
|
||||||
alt="Next.js logo"
|
Perjalanan Nyaman,<br />
|
||||||
width={180}
|
Mudah & Instan.
|
||||||
height={38}
|
</h1>
|
||||||
priority
|
<p className={styles.heroSubtitle}>
|
||||||
/>
|
Pesan tiket travel dan bis antarkota terpercaya secara online. Pilih kursi favorit Anda dengan visual map interaktif.
|
||||||
<ol>
|
</p>
|
||||||
<li>
|
|
||||||
Get started by editing <code>src/app/page.tsx</code>.
|
|
||||||
</li>
|
|
||||||
<li>Save and see your changes instantly.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className={styles.ctas}>
|
|
||||||
<a
|
|
||||||
className={styles.primary}
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className={styles.logo}
|
|
||||||
src="https://nextjs.org/icons/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={styles.secondary}
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
<footer className={styles.footer}>
|
<div className={styles.searchContainer}>
|
||||||
<a
|
<SearchWidget
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
departureCities={departureCities}
|
||||||
target="_blank"
|
arrivalCities={arrivalCities}
|
||||||
rel="noopener noreferrer"
|
routes={routes.map(r => ({ ...r, basePrice: Number(r.basePrice) }))}
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="https://nextjs.org/icons/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
/>
|
||||||
Learn
|
</div>
|
||||||
</a>
|
</section>
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
{/* Popular Routes Section */}
|
||||||
target="_blank"
|
<section className={styles.section}>
|
||||||
rel="noopener noreferrer"
|
<h2 className={styles.sectionTitle}>Rute Populer Keberangkatan</h2>
|
||||||
>
|
<div className={styles.routesGrid}>
|
||||||
<Image
|
<div className="glass-panel" style={{ padding: '24px' }}>
|
||||||
aria-hidden
|
<div style={{ fontSize: '2rem', marginBottom: '10px', color: 'var(--primary)' }}><FaBuilding /></div>
|
||||||
src="https://nextjs.org/icons/window.svg"
|
<h3>Jakarta → Bandung</h3>
|
||||||
alt="Window icon"
|
<p style={{ color: 'var(--text-secondary)', marginTop: '8px', fontSize: '0.9rem' }}>Mulai dari Rp 130.000</p>
|
||||||
width={16}
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '4px' }}>Armada: HiAce & Bis Eksekutif</p>
|
||||||
height={16}
|
</div>
|
||||||
/>
|
<div className="glass-panel" style={{ padding: '24px' }}>
|
||||||
Examples
|
<div style={{ fontSize: '2rem', marginBottom: '10px', color: 'var(--primary)' }}><FaMountain /></div>
|
||||||
</a>
|
<h3>Bandung → Jakarta</h3>
|
||||||
<a
|
<p style={{ color: 'var(--text-secondary)', marginTop: '8px', fontSize: '0.9rem' }}>Mulai dari Rp 130.000</p>
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '4px' }}>Armada: HiAce & Bis Eksekutif</p>
|
||||||
target="_blank"
|
</div>
|
||||||
rel="noopener noreferrer"
|
<div className="glass-panel" style={{ padding: '24px' }}>
|
||||||
>
|
<div style={{ fontSize: '2rem', marginBottom: '10px', color: 'var(--primary)' }}><FaTree /></div>
|
||||||
<Image
|
<h3>Jakarta → Bogor</h3>
|
||||||
aria-hidden
|
<p style={{ color: 'var(--text-secondary)', marginTop: '8px', fontSize: '0.9rem' }}>Mulai dari Rp 75.000</p>
|
||||||
src="https://nextjs.org/icons/globe.svg"
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.8rem', marginTop: '4px' }}>Armada: Toyota HiAce Shuttle</p>
|
||||||
alt="Globe icon"
|
</div>
|
||||||
width={16}
|
</div>
|
||||||
height={16}
|
</section>
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
{/* Why Choose Us Section */}
|
||||||
</a>
|
<section className={styles.section}>
|
||||||
</footer>
|
<h2 className={styles.sectionTitle}>Kenapa Memilih {settings.brandName} Travel?</h2>
|
||||||
|
<div className={styles.featuresGrid}>
|
||||||
|
<div className={styles.featureCard}>
|
||||||
|
<span className={styles.featureIcon}><FaChair /></span>
|
||||||
|
<h4>Pilih Kursi Terpilih</h4>
|
||||||
|
<p>Tentukan sendiri posisi kursi kesukaan Anda langsung di seat map interaktif kami saat booking.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.featureCard}>
|
||||||
|
<span className={styles.featureIcon}><FaBolt /></span>
|
||||||
|
<h4>Proses Cepat & Instan</h4>
|
||||||
|
<p>Selesaikan pemesanan tiket Anda dalam hitungan menit dan dapatkan tiket digital secara instan.</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.featureCard}>
|
||||||
|
<span className={styles.featureIcon}><FaShieldAlt /></span>
|
||||||
|
<h4>100% Terpercaya</h4>
|
||||||
|
<p>Semua keberangkatan terintegrasi dengan database jadwal real-time untuk menjamin keberangkatan Anda.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaUserPlus } from 'react-icons/fa';
|
||||||
|
import styles from './register.module.css';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [brandName, setBrandName] = useState('AntarKota');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBrand() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setBrandName(data.settings.brandName);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchBrand();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name || !email || !password || !phone) {
|
||||||
|
setError('Silakan isi seluruh informasi pendaftaran.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, email, password, phone }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Terjadi kesalahan pendaftaran.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful registration & auto-login, redirect to homepage
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Gagal mendaftar. Silakan coba kembali.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={`${styles.card} glass-panel`}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.logoIcon}><FaUserPlus /></span>
|
||||||
|
<h2>Daftar Akun Baru</h2>
|
||||||
|
<p>Mulai perjalanan Anda dengan membuat akun travel {brandName}.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleRegister} className={styles.form}>
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nama Lengkap</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: Budi Santoso"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Alamat Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: budi@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">No. Telepon / WhatsApp</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: 081234567890"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="Minimal 6 karakter..."
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary" style={{ width: '100%', marginTop: '10px' }}>
|
||||||
|
{loading ? 'Mendaftarkan...' : 'Daftar Sekarang'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className={styles.footerText}>
|
||||||
|
Sudah punya akun? <Link href="/login" className={styles.link}>Masuk di sini</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 70px - 340px);
|
||||||
|
padding: 40px 20px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 3rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerText {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 2.6rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaCalendarAlt, FaUser, FaBus, FaShuttleVan } from 'react-icons/fa';
|
||||||
|
import styles from './search.module.css';
|
||||||
|
|
||||||
|
interface Schedule {
|
||||||
|
id: number;
|
||||||
|
routeId: number;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
capacity: number;
|
||||||
|
price: number;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
occupiedSeats: string[];
|
||||||
|
availableSeatsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultsContent() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const from = searchParams.get('from') || '';
|
||||||
|
const to = searchParams.get('to') || '';
|
||||||
|
const date = searchParams.get('date') || '';
|
||||||
|
const passengers = Number(searchParams.get('passengers') || '1');
|
||||||
|
|
||||||
|
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [filteredSchedules, setFilteredSchedules] = useState<Schedule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Filters and sorting
|
||||||
|
const [vehicleFilter, setVehicleFilter] = useState('ALL'); // ALL, HIACE, BUS
|
||||||
|
const [sortBy, setSortBy] = useState('DEPARTURE_EARLIEST'); // DEPARTURE_EARLIEST, DEPARTURE_LATEST, PRICE_LOWEST, PRICE_HIGHEST
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!from || !to || !date) {
|
||||||
|
router.push('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSchedules() {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/schedules?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&date=${date}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Gagal memuat jadwal keberangkatan.');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setSchedules(data.schedules);
|
||||||
|
setFilteredSchedules(data.schedules);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Terjadi kesalahan sistem.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSchedules();
|
||||||
|
}, [from, to, date, router]);
|
||||||
|
|
||||||
|
// Apply filters and sorting
|
||||||
|
useEffect(() => {
|
||||||
|
let result = [...schedules];
|
||||||
|
|
||||||
|
// Filter by vehicle type
|
||||||
|
if (vehicleFilter === 'HIACE') {
|
||||||
|
result = result.filter(s => s.vehicleType.toLowerCase().includes('hiace'));
|
||||||
|
} else if (vehicleFilter === 'BUS') {
|
||||||
|
result = result.filter(s => s.vehicleType.toLowerCase().includes('bus'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort schedules
|
||||||
|
if (sortBy === 'DEPARTURE_EARLIEST') {
|
||||||
|
result.sort((a, b) => new Date(a.departureTime).getTime() - new Date(b.departureTime).getTime());
|
||||||
|
} else if (sortBy === 'DEPARTURE_LATEST') {
|
||||||
|
result.sort((a, b) => new Date(b.departureTime).getTime() - new Date(a.departureTime).getTime());
|
||||||
|
} else if (sortBy === 'PRICE_LOWEST') {
|
||||||
|
result.sort((a, b) => a.price - b.price);
|
||||||
|
} else if (sortBy === 'PRICE_HIGHEST') {
|
||||||
|
result.sort((a, b) => b.price - a.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredSchedules(result);
|
||||||
|
}, [schedules, vehicleFilter, sortBy]);
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
const d = new Date(timeStr);
|
||||||
|
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Mencari jadwal terbaik untuk Anda...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Search Header Info */}
|
||||||
|
<div className={`${styles.searchHeader} glass-panel`}>
|
||||||
|
<div className={styles.searchDetails}>
|
||||||
|
<div className={styles.routeText}>
|
||||||
|
<span>{from}</span>
|
||||||
|
<span className={styles.arrow}>→</span>
|
||||||
|
<span>{to}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.metaText}>
|
||||||
|
<span><FaCalendarAlt style={{ marginRight: '6px' }} /> {formatDate(date)}</span>
|
||||||
|
<span className={styles.divider}>|</span>
|
||||||
|
<span><FaUser style={{ marginRight: '6px' }} /> {passengers} Penumpang</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/" className="btn btn-secondary" style={{ padding: '8px 16px', fontSize: '0.85rem' }}>
|
||||||
|
Ubah Pencarian
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.layout}>
|
||||||
|
{/* Sidebar Filters */}
|
||||||
|
<aside className={`${styles.filters} glass-panel`}>
|
||||||
|
<h3>Filter & Urutkan</h3>
|
||||||
|
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<label className="form-label">Urutkan Berdasarkan</label>
|
||||||
|
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="form-input">
|
||||||
|
<option value="DEPARTURE_EARLIEST">Keberangkatan Terawal</option>
|
||||||
|
<option value="DEPARTURE_LATEST">Keberangkatan Terakhir</option>
|
||||||
|
<option value="PRICE_LOWEST">Harga Terendah</option>
|
||||||
|
<option value="PRICE_HIGHEST">Harga Tertinggi</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.filterGroup}>
|
||||||
|
<label className="form-label">Tipe Armada</label>
|
||||||
|
<div className={styles.radioGroup}>
|
||||||
|
<label className={styles.radioLabel}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="vehicle"
|
||||||
|
checked={vehicleFilter === 'ALL'}
|
||||||
|
onChange={() => setVehicleFilter('ALL')}
|
||||||
|
/>
|
||||||
|
Semua Armada
|
||||||
|
</label>
|
||||||
|
<label className={styles.radioLabel}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="vehicle"
|
||||||
|
checked={vehicleFilter === 'HIACE'}
|
||||||
|
onChange={() => setVehicleFilter('HIACE')}
|
||||||
|
/>
|
||||||
|
Toyota HiAce (Shuttle)
|
||||||
|
</label>
|
||||||
|
<label className={styles.radioLabel}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="vehicle"
|
||||||
|
checked={vehicleFilter === 'BUS'}
|
||||||
|
onChange={() => setVehicleFilter('BUS')}
|
||||||
|
/>
|
||||||
|
Executive Bus (Bis)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Schedules list */}
|
||||||
|
<main className={styles.results}>
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
{filteredSchedules.length === 0 ? (
|
||||||
|
<div className={`${styles.emptyState} glass-panel`}>
|
||||||
|
<div className={styles.emptyIcon}><FaBus /></div>
|
||||||
|
<h3>Jadwal Tidak Ditemukan</h3>
|
||||||
|
<p>Maaf, tidak ada jadwal perjalanan yang cocok dengan kriteria pencarian Anda. Silakan ubah tanggal atau pilih rute lain.</p>
|
||||||
|
<Link href="/" className="btn btn-primary" style={{ marginTop: '20px' }}>
|
||||||
|
Kembali ke Pencarian
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.cardsList}>
|
||||||
|
{filteredSchedules.map((schedule) => (
|
||||||
|
<div key={schedule.id} className={`${styles.scheduleCard} glass-panel`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div className={styles.vehicleInfo}>
|
||||||
|
<span className={styles.vehicleIcon}>
|
||||||
|
{schedule.vehicleType.toLowerCase().includes('hiace') ? <FaShuttleVan /> : <FaBus />}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h4 className={styles.vehicleType}>{schedule.vehicleType}</h4>
|
||||||
|
<span className={styles.capacityLabel}>{schedule.capacity} Kursi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceContainer}>
|
||||||
|
<span className={styles.priceLabel}>Harga per orang</span>
|
||||||
|
<h3 className={styles.priceValue}>{formatCurrency(schedule.price)}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
<div className={styles.timeline}>
|
||||||
|
<div className={styles.timelinePoint}>
|
||||||
|
<span className={styles.time}>{formatTime(schedule.departureTime)}</span>
|
||||||
|
<span className={styles.city}>{schedule.departureCity}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.durationLine}>
|
||||||
|
<span className={styles.duration}>
|
||||||
|
{Math.floor(schedule.durationMinutes / 60)}j {schedule.durationMinutes % 60}m
|
||||||
|
</span>
|
||||||
|
<div className={styles.line}></div>
|
||||||
|
<span className={styles.directLabel}>Langsung</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.timelinePoint}>
|
||||||
|
<span className={styles.time}>{formatTime(schedule.arrivalTime)}</span>
|
||||||
|
<span className={styles.city}>{schedule.arrivalCity}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardFooter}>
|
||||||
|
<span className={`${styles.seatsLeft} ${schedule.availableSeatsCount <= 3 ? styles.seatsWarning : ''}`}>
|
||||||
|
{schedule.availableSeatsCount > 0
|
||||||
|
? `${schedule.availableSeatsCount} Kursi Tersedia`
|
||||||
|
: 'Habis Terjual'}
|
||||||
|
</span>
|
||||||
|
{schedule.availableSeatsCount >= passengers ? (
|
||||||
|
<Link
|
||||||
|
href={`/booking/${schedule.id}?passengers=${passengers}`}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ padding: '8px 20px', fontSize: '0.9rem' }}
|
||||||
|
>
|
||||||
|
Pilih Kursi & Pesan
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<button className="btn btn-secondary" disabled style={{ cursor: 'not-allowed', opacity: 0.6 }}>
|
||||||
|
{schedule.availableSeatsCount > 0 ? 'Kursi Kurang' : 'Penuh'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchResultsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<div className={styles.spinner}></div>
|
||||||
|
<p>Memuat hasil pencarian...</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<SearchResultsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 30px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50vh;
|
||||||
|
gap: 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid var(--border-light);
|
||||||
|
border-top-color: var(--text-white);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 30px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeText {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaText {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
padding: 24px;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filterGroup {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioLabel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioLabel:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyIcon {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState p {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardsList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleCard {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduleCard:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleInfo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleIcon {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleType {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capacityLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceContainer {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceValue {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelinePoint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelinePoint:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.durationLine {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border-light);
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before,
|
||||||
|
.line::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::after {
|
||||||
|
right: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.directLabel {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsLeft {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatsWarning {
|
||||||
|
color: var(--accent-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness for Search Results */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchDetails {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.scheduleCard {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceContainer {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.routeText {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaText {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transform timeline to vertical for better mobile spacing */
|
||||||
|
.timeline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timelinePoint {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
min-width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.durationLine {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
top: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
height: auto;
|
||||||
|
width: 2px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before,
|
||||||
|
.line::after {
|
||||||
|
left: -3px;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::after {
|
||||||
|
bottom: 0;
|
||||||
|
top: auto;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration,
|
||||||
|
.directLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardFooter .btn,
|
||||||
|
.cardFooter button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { FaTicketAlt } from 'react-icons/fa';
|
||||||
|
import styles from './ticketcheck.module.css';
|
||||||
|
|
||||||
|
export default function TicketCheckPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!code) {
|
||||||
|
setError('Silakan masukkan Kode Booking Anda.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cleanCode = code.trim().toUpperCase();
|
||||||
|
const res = await fetch(`/api/bookings/${cleanCode}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Kode booking tidak ditemukan. Silakan periksa kembali.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const booking = data.booking;
|
||||||
|
|
||||||
|
if (booking.status === 'PAID') {
|
||||||
|
router.push(`/ticket/${cleanCode}`);
|
||||||
|
} else {
|
||||||
|
router.push(`/booking/checkout/${cleanCode}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Terjadi kesalahan saat mencari tiket.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={`${styles.card} glass-panel`}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.icon}><FaTicketAlt /></span>
|
||||||
|
<h2>Cek Pemesanan Tiket</h2>
|
||||||
|
<p>Masukkan Kode Booking Anda untuk melihat status pembayaran, mengakses tiket digital, atau melanjutkan pembayaran.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Kode Booking (TRV-XXXXXX)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="TRV-XXXXXX"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ textAlign: 'center', letterSpacing: '0.1em', fontSize: '1.2rem', textTransform: 'uppercase' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading} className="btn btn-primary" style={{ width: '100%', marginTop: '10px' }}>
|
||||||
|
{loading ? 'Mencari Tiket...' : 'Periksa Status Tiket'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 70px - 340px);
|
||||||
|
padding: 40px 20px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { prisma } from '@/lib/db';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import TicketView from '@/components/TicketView';
|
||||||
|
import { getSettings } from '@/lib/settings';
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function TicketPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { code: string };
|
||||||
|
}) {
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { bookingCode: params.code },
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
const serializedBooking = {
|
||||||
|
bookingCode: booking.bookingCode,
|
||||||
|
passengerName: booking.passengerName,
|
||||||
|
passengerEmail: booking.passengerEmail,
|
||||||
|
passengerPhone: booking.passengerPhone,
|
||||||
|
totalPrice: Number(booking.totalPrice),
|
||||||
|
status: booking.status,
|
||||||
|
paymentMethod: booking.paymentMethod,
|
||||||
|
paymentTime: booking.paymentTime ? booking.paymentTime.toISOString() : null,
|
||||||
|
createdAt: booking.createdAt.toISOString(),
|
||||||
|
seats: booking.seats.map((s) => s.seatNumber),
|
||||||
|
schedule: {
|
||||||
|
departureTime: booking.schedule.departureTime.toISOString(),
|
||||||
|
arrivalTime: booking.schedule.arrivalTime.toISOString(),
|
||||||
|
vehicleType: booking.schedule.vehicleType,
|
||||||
|
departureCity: booking.schedule.route.departureCity,
|
||||||
|
arrivalCity: booking.schedule.route.arrivalCity,
|
||||||
|
durationMinutes: booking.schedule.route.durationMinutes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <TicketView booking={serializedBooking} settings={settings} />;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tripBanner {
|
||||||
|
padding: 20px 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeHeader h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleTag {
|
||||||
|
background: color-mix(in srgb, var(--primary) 15%, transparent);
|
||||||
|
color: var(--text-white);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeTimes {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatSelectorSection {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionSubtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendColor {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendAvailable {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendSelected {
|
||||||
|
background: var(--primary);
|
||||||
|
box-shadow: 0 0 10px color-mix(in srgb, var(--primary) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendOccupied {
|
||||||
|
background: rgba(244, 63, 94, 0.2);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinWrapper {
|
||||||
|
background: color-mix(in srgb, var(--primary) 5%, rgba(10, 15, 30, 0.6));
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-radius: 24px 24px 12px 12px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 30px 24px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steeringWheel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px dashed var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hiace Grid Styling */
|
||||||
|
.hiaceGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.driverSeat {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-muted);
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bus Grid Styling */
|
||||||
|
.busGrid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.busRowGroup {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aisle {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Seat Button Style */
|
||||||
|
.seat {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
color: var(--text-white);
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat:hover:not(.occupied) {
|
||||||
|
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||||
|
border-color: var(--text-white);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat.selected {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--text-white);
|
||||||
|
box-shadow: 0 0 15px color-mix(in srgb, var(--primary) 60%, transparent);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat.occupied {
|
||||||
|
background: rgba(244, 63, 94, 0.15);
|
||||||
|
border-color: rgba(244, 63, 94, 0.3);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form panel styling */
|
||||||
|
.bookingForm {
|
||||||
|
padding: 30px;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSummary {
|
||||||
|
margin: 30px 0;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedSeatsText {
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalRow {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalValue {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn:disabled:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamicGrid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
width: max-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyCell {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridScrollContainer {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
|
padding: 4px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--primary) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridScrollContainer::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridScrollContainer::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gridScrollContainer::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness for Booking Form */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tripBanner {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeHeader h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeTimes {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatSelectorSection {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookingForm {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cabinWrapper {
|
||||||
|
padding: 20px 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.legendItem {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSummary {
|
||||||
|
padding: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalRow {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalValue {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { FaCalendarAlt, FaClock, FaHourglass, FaCog } from 'react-icons/fa';
|
||||||
|
import styles from './BookingForm.module.css';
|
||||||
|
|
||||||
|
interface Schedule {
|
||||||
|
id: number;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
capacity: number;
|
||||||
|
price: number;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
occupiedSeats: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingFormProps {
|
||||||
|
schedule: Schedule;
|
||||||
|
passengersCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingForm({ schedule, passengersCount }: BookingFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Form states
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [layouts, setLayouts] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadLayouts() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/settings');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.settings?.vehicleLayouts) {
|
||||||
|
setLayouts(JSON.parse(data.settings.vehicleLayouts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching vehicle layouts:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadLayouts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { occupiedSeats, vehicleType, price } = schedule;
|
||||||
|
|
||||||
|
const normalizeName = (nameStr: string) => {
|
||||||
|
return (nameStr || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\([^)]*\)/g, '') // Remove parenthesis like (10 Seater)
|
||||||
|
.replace(/[^a-z0-9]/g, ''); // Remove non-alphanumeric characters
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeLayout = layouts.find(
|
||||||
|
(l) => normalizeName(l.name) === normalizeName(vehicleType)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate seat map arrays
|
||||||
|
const isHiace = vehicleType.toLowerCase().includes('hiace');
|
||||||
|
|
||||||
|
const hiaceSeats = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'];
|
||||||
|
|
||||||
|
const busRows = [
|
||||||
|
{ row: 1, seats: ['1A', '1B', '1C', '1D'] },
|
||||||
|
{ row: 2, seats: ['2A', '2B', '2C', '2D'] },
|
||||||
|
{ row: 3, seats: ['3A', '3B', '3C', '3D'] },
|
||||||
|
{ row: 4, seats: ['4A', '4B', '4C', '4D'] },
|
||||||
|
{ row: 5, seats: ['5A', '5B', '5C', '5D'] },
|
||||||
|
{ row: 6, seats: ['6A', '6B', '6C', '6D'] },
|
||||||
|
{ row: 7, seats: ['7A', '7B', '7C', '7D'] },
|
||||||
|
{ row: 8, seats: ['8A', '8B'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSeatClick = (seatId: string) => {
|
||||||
|
if (occupiedSeats.includes(seatId)) return;
|
||||||
|
|
||||||
|
if (selectedSeats.includes(seatId)) {
|
||||||
|
setSelectedSeats(selectedSeats.filter(s => s !== seatId));
|
||||||
|
setError('');
|
||||||
|
} else {
|
||||||
|
if (selectedSeats.length >= passengersCount) {
|
||||||
|
setError(`Anda mencari tiket untuk ${passengersCount} penumpang. Hanya boleh memilih ${passengersCount} kursi.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedSeats([...selectedSeats, seatId]);
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (selectedSeats.length !== passengersCount) {
|
||||||
|
setError(`Silakan pilih tepat ${passengersCount} kursi untuk melanjutkan.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name || !email || !phone) {
|
||||||
|
setError('Silakan isi seluruh informasi penumpang.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
passengerName: name,
|
||||||
|
passengerEmail: email,
|
||||||
|
passengerPhone: phone,
|
||||||
|
seats: selectedSeats,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Terjadi kesalahan saat memproses booking.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful booking, redirect to payment checkout screen
|
||||||
|
router.push(`/booking/checkout/${data.bookingCode}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Gagal memproses pemesanan. Silakan coba kembali.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
const d = new Date(timeStr);
|
||||||
|
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Upper Trip Banner Summary */}
|
||||||
|
<div className={`${styles.tripBanner} glass-panel`}>
|
||||||
|
<div className={styles.routeHeader}>
|
||||||
|
<h3>{schedule.departureCity} → {schedule.arrivalCity}</h3>
|
||||||
|
<span className={styles.vehicleTag}>{vehicleType}</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.routeTimes}>
|
||||||
|
<p><FaCalendarAlt style={{ marginRight: '6px' }} /> {formatDate(schedule.departureTime)}</p>
|
||||||
|
<p><FaClock style={{ marginRight: '6px' }} /> Keberangkatan: <strong>{formatTime(schedule.departureTime)} WIB</strong></p>
|
||||||
|
<p><FaHourglass style={{ marginRight: '6px' }} /> Durasi: {Math.floor(schedule.durationMinutes / 60)}j {schedule.durationMinutes % 60}m</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.layout}>
|
||||||
|
{/* Visual Seat Map */}
|
||||||
|
<div className={`${styles.seatSelectorSection} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle}>Pilih Kursi Penumpang</h3>
|
||||||
|
<p className={styles.sectionSubtitle}>Silakan pilih <strong>{passengersCount} kursi</strong>. Kursi yang Anda pilih ditandai warna Indigo.</p>
|
||||||
|
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<span className={`${styles.legendColor} ${styles.legendAvailable}`}></span>
|
||||||
|
<span>Tersedia</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<span className={`${styles.legendColor} ${styles.legendSelected}`}></span>
|
||||||
|
<span>Pilihan Anda</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.legendItem}>
|
||||||
|
<span className={`${styles.legendColor} ${styles.legendOccupied}`}></span>
|
||||||
|
<span>Terisi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cabinWrapper}>
|
||||||
|
<div className={styles.steeringWheel}>
|
||||||
|
<span><FaCog style={{ marginRight: '4px' }} /> Kemudi</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.gridScrollContainer}>
|
||||||
|
{activeLayout ? (
|
||||||
|
/* Custom Seating Layout */
|
||||||
|
<div
|
||||||
|
className={styles.dynamicGrid}
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${activeLayout.cols}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: activeLayout.rows }).map((_, rIdx) =>
|
||||||
|
Array.from({ length: activeLayout.cols }).map((_, cIdx) => {
|
||||||
|
const seat = activeLayout.seats.find((s: any) => s.row === rIdx && s.col === cIdx);
|
||||||
|
if (seat) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${rIdx}-${cIdx}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick(seat.label)}
|
||||||
|
disabled={occupiedSeats.includes(seat.label)}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes(seat.label) ? styles.selected : ''} ${occupiedSeats.includes(seat.label) ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
{seat.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <div key={`${rIdx}-${cIdx}`} className={styles.emptyCell} />;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isHiace ? (
|
||||||
|
/* HiAce Layout: 10 Seater */
|
||||||
|
<div className={styles.hiaceGrid}>
|
||||||
|
{/* Row 1: Driver space & Seat 1 */}
|
||||||
|
<div className={styles.seatRow}>
|
||||||
|
<div className={styles.driverSeat}>Sopir</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick('1')}
|
||||||
|
disabled={occupiedSeats.includes('1')}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes('1') ? styles.selected : ''} ${occupiedSeats.includes('1') ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: 2, 3, 4 */}
|
||||||
|
<div className={styles.seatRow}>
|
||||||
|
{['2', '3', '4'].map(seat => (
|
||||||
|
<button
|
||||||
|
key={seat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick(seat)}
|
||||||
|
disabled={occupiedSeats.includes(seat)}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes(seat) ? styles.selected : ''} ${occupiedSeats.includes(seat) ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
{seat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: 5, 6, 7 */}
|
||||||
|
<div className={styles.seatRow}>
|
||||||
|
{['5', '6', '7'].map(seat => (
|
||||||
|
<button
|
||||||
|
key={seat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick(seat)}
|
||||||
|
disabled={occupiedSeats.includes(seat)}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes(seat) ? styles.selected : ''} ${occupiedSeats.includes(seat) ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
{seat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: 8, 9, 10 */}
|
||||||
|
<div className={styles.seatRow}>
|
||||||
|
{['8', '9', '10'].map(seat => (
|
||||||
|
<button
|
||||||
|
key={seat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick(seat)}
|
||||||
|
disabled={occupiedSeats.includes(seat)}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes(seat) ? styles.selected : ''} ${occupiedSeats.includes(seat) ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
{seat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Executive Bus Layout: 30 Seater */
|
||||||
|
<div className={styles.busGrid}>
|
||||||
|
{busRows.map(rowObj => (
|
||||||
|
<div key={rowObj.row} className={styles.busRow}>
|
||||||
|
{/* Left side: A, B */}
|
||||||
|
<div className={styles.busRowGroup}>
|
||||||
|
{rowObj.seats.slice(0, 2).map(seat => (
|
||||||
|
<button
|
||||||
|
key={seat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick(seat)}
|
||||||
|
disabled={occupiedSeats.includes(seat)}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes(seat) ? styles.selected : ''} ${occupiedSeats.includes(seat) ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
{seat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aisle Spacer */}
|
||||||
|
<div className={styles.aisle}></div>
|
||||||
|
|
||||||
|
{/* Right side: C, D (or whatever seats remain) */}
|
||||||
|
<div className={styles.busRowGroup}>
|
||||||
|
{rowObj.seats.slice(2, 4).map(seat => (
|
||||||
|
<button
|
||||||
|
key={seat}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSeatClick(seat)}
|
||||||
|
disabled={occupiedSeats.includes(seat)}
|
||||||
|
className={`${styles.seat} ${selectedSeats.includes(seat) ? styles.selected : ''} ${occupiedSeats.includes(seat) ? styles.occupied : ''}`}
|
||||||
|
>
|
||||||
|
{seat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Passenger Information and Payment Summary Form */}
|
||||||
|
<form onSubmit={handleSubmit} className={`${styles.bookingForm} glass-panel`}>
|
||||||
|
<h3 className={styles.sectionTitle}>Detail Kontak Penumpang</h3>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Nama Lengkap Penumpang</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: Budi Santoso"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Alamat Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: budi@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">No. Telepon / WhatsApp</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
placeholder="Contoh: 081234567890"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing Breakdowns */}
|
||||||
|
<div className={styles.priceSummary}>
|
||||||
|
<div className={styles.summaryRow}>
|
||||||
|
<span>Kursi Terpilih</span>
|
||||||
|
<strong className={styles.selectedSeatsText}>
|
||||||
|
{selectedSeats.length > 0 ? selectedSeats.join(', ') : '-'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryRow}>
|
||||||
|
<span>Tarif Tiket ({selectedSeats.length}x)</span>
|
||||||
|
<span>{formatCurrency(price * selectedSeats.length)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.summaryRow} ${styles.totalRow}`}>
|
||||||
|
<span>Total Pembayaran</span>
|
||||||
|
<span className={styles.totalValue}>{formatCurrency(price * selectedSeats.length)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || selectedSeats.length !== passengersCount}
|
||||||
|
className={`btn btn-primary ${styles.submitBtn}`}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Memproses Pemesanan...' : 'Lanjutkan ke Pembayaran'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerAlert {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerCount {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiredAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.25);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successAlert {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||||
|
color: var(--accent-emerald);
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryCard {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookingCode {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeDetails,
|
||||||
|
.passengerInfo {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cityRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleTag {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlightText {
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSummary {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalRow {
|
||||||
|
border-top: 1px dashed var(--border-light);
|
||||||
|
padding-top: 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalValue {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionCard {
|
||||||
|
padding: 30px;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelTitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelSubtitle {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorAlert {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paymentMethods {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodLabel {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodLabel:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedMethod {
|
||||||
|
border-color: var(--text-white);
|
||||||
|
background: color-mix(in srgb, var(--primary) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radioInput {
|
||||||
|
accent-color: var(--text-white);
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodIcon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodDesc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paid confirmation styles */
|
||||||
|
.ticketSection {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.successIcon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketSection h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketSection p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expired State styles */
|
||||||
|
.expiredSection {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiredIcon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiredSection h3 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expiredSection p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness for Checkout Page */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryCard {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionCard {
|
||||||
|
padding: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerAlert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.timerAlert {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timerCount {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookingCode {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceSummary {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryRow {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalRow {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.totalValue {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodLabel {
|
||||||
|
padding: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.methodIcon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaClock, FaExclamationTriangle, FaCheckCircle, FaCalendarAlt, FaCouch, FaSync, FaMobileAlt, FaMoneyBillWave, FaGift, FaTicketAlt, FaTimesCircle } from 'react-icons/fa';
|
||||||
|
import styles from './CheckoutClient.module.css';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: number;
|
||||||
|
bookingCode: string;
|
||||||
|
passengerName: string;
|
||||||
|
passengerEmail: string;
|
||||||
|
passengerPhone: string;
|
||||||
|
totalPrice: number;
|
||||||
|
status: string; // PENDING, PAID, CANCELLED
|
||||||
|
paymentMethod: string | null;
|
||||||
|
paymentTime: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
seats: string[];
|
||||||
|
schedule: {
|
||||||
|
id: number;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
price: number;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CheckoutClient({ booking, settings }: { booking: Booking; settings?: any }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState(booking.paymentMethod || '');
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState(booking.paymentMethod || 'QRIS');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isPaying, setIsPaying] = useState(false);
|
||||||
|
const [status, setStatus] = useState(booking.status);
|
||||||
|
const [checkingStatus, setCheckingStatus] = useState(false);
|
||||||
|
const [originUrl, setOriginUrl] = useState('');
|
||||||
|
const [qrisString, setQrisString] = useState('');
|
||||||
|
const [loadingQris, setLoadingQris] = useState(false);
|
||||||
|
|
||||||
|
// Countdown timer for pending bookings (15 minutes from creation)
|
||||||
|
const [timeLeft, setTimeLeft] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
setOriginUrl(window.location.origin);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'PENDING') return;
|
||||||
|
|
||||||
|
const expiryTime = new Date(booking.createdAt).getTime() + 15 * 60 * 1000;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
const distance = expiryTime - now;
|
||||||
|
|
||||||
|
if (distance < 0) {
|
||||||
|
setTimeLeft('EXPIRED');
|
||||||
|
clearInterval(interval);
|
||||||
|
} else {
|
||||||
|
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||||
|
setTimeLeft(`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [booking.createdAt, status]);
|
||||||
|
|
||||||
|
// Polling for QRIS transactions
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'PENDING' || paymentMethod !== 'QRIS') return;
|
||||||
|
|
||||||
|
checkPakasirStatus(true);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
checkPakasirStatus(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status, paymentMethod]);
|
||||||
|
|
||||||
|
// Fetch QRIS string from Pakasir on-demand
|
||||||
|
useEffect(() => {
|
||||||
|
if (paymentMethod !== 'QRIS' || status !== 'PENDING') return;
|
||||||
|
|
||||||
|
async function getQris() {
|
||||||
|
setLoadingQris(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bookings/${booking.bookingCode}/qris`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.qrisString) {
|
||||||
|
setQrisString(data.qrisString);
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Gagal memuat QRIS dari Pakasir.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching QRIS:', err);
|
||||||
|
setError('Koneksi internet bermasalah saat memuat QRIS.');
|
||||||
|
} finally {
|
||||||
|
setLoadingQris(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getQris();
|
||||||
|
}, [paymentMethod, booking.bookingCode, status]);
|
||||||
|
|
||||||
|
const checkPakasirStatus = async (showLoading = false) => {
|
||||||
|
if (showLoading) setCheckingStatus(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bookings/${booking.bookingCode}/check-pakasir`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'PAID') {
|
||||||
|
setStatus('PAID');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking status:', err);
|
||||||
|
} finally {
|
||||||
|
if (showLoading) setCheckingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectTunai = async () => {
|
||||||
|
setIsPaying(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bookings/${booking.bookingCode}/pay`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paymentMethod: 'TUNAI' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Gagal memilih metode pembayaran.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaymentMethod('TUNAI');
|
||||||
|
router.refresh();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Gagal memproses.');
|
||||||
|
} finally {
|
||||||
|
setIsPaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectQris = async () => {
|
||||||
|
setIsPaying(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/bookings/${booking.bookingCode}/pay`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paymentMethod: 'QRIS' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Gagal memproses pembayaran.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaymentMethod('QRIS');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Gagal memproses pembayaran.');
|
||||||
|
} finally {
|
||||||
|
setIsPaying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetMethod = () => {
|
||||||
|
setPaymentMethod('');
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
const d = new Date(timeStr);
|
||||||
|
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const brandName = settings?.brandName || 'AntarKota';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{/* Timer Bar */}
|
||||||
|
{status === 'PENDING' && timeLeft !== 'EXPIRED' && (
|
||||||
|
<div className={`${styles.timerAlert} glass-panel`}>
|
||||||
|
<span><FaClock style={{ marginRight: '6px' }} /> Selesaikan pembayaran sebelum batas waktu berakhir:</span>
|
||||||
|
<strong className={styles.timerCount}>{timeLeft}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'PENDING' && timeLeft === 'EXPIRED' && (
|
||||||
|
<div className={`${styles.expiredAlert} glass-panel`}>
|
||||||
|
<span><FaExclamationTriangle style={{ marginRight: '6px' }} /> Batas waktu pembayaran telah habis. Silakan buat pemesanan ulang.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'PAID' && (
|
||||||
|
<div className={`${styles.successAlert} glass-panel`}>
|
||||||
|
<span><FaCheckCircle style={{ marginRight: '6px' }} /> Pembayaran Sukses! Tiket Anda telah aktif dan dikonfirmasi.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.layout}>
|
||||||
|
{/* Left Side: Booking & Route Summary */}
|
||||||
|
<div className={`${styles.summaryCard} glass-panel`}>
|
||||||
|
<div className={styles.cardHeader}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>KODE BOOKING</span>
|
||||||
|
<h3 className={styles.bookingCode}>{booking.bookingCode}</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>STATUS</span>
|
||||||
|
<div>
|
||||||
|
<span className={`badge ${status === 'PAID' ? 'badge-paid' : status === 'CANCELLED' ? 'badge-cancelled' : 'badge-pending'}`}>
|
||||||
|
{status === 'PAID' ? 'Lunas' : status === 'CANCELLED' ? 'Dibatalkan' : 'Menunggu Pembayaran'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cardBody}>
|
||||||
|
{/* Route Details */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>Detail Rute Perjalanan</h4>
|
||||||
|
<div className={styles.routeDetails}>
|
||||||
|
<div className={styles.cityRow}>
|
||||||
|
<strong>{booking.schedule.departureCity} → {booking.schedule.arrivalCity}</strong>
|
||||||
|
<span className={styles.vehicleTag}>{booking.schedule.vehicleType}</span>
|
||||||
|
</div>
|
||||||
|
<p><FaCalendarAlt style={{ marginRight: '6px' }} /> {formatDate(booking.schedule.departureTime)}</p>
|
||||||
|
<p><FaClock style={{ marginRight: '6px' }} /> Keberangkatan: {formatTime(booking.schedule.departureTime)} WIB</p>
|
||||||
|
<p><FaCouch style={{ marginRight: '6px' }} /> Nomor Kursi: <strong className={styles.highlightText}>{booking.seats.join(', ')}</strong></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Passenger Details */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>Informasi Penumpang</h4>
|
||||||
|
<div className={styles.passengerInfo}>
|
||||||
|
<p>Nama: <strong>{booking.passengerName}</strong></p>
|
||||||
|
<p>Email: {booking.passengerEmail}</p>
|
||||||
|
<p>WhatsApp: {booking.passengerPhone}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price details */}
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h4 className={styles.sectionTitle}>Rincian Biaya</h4>
|
||||||
|
<div className={styles.priceSummary}>
|
||||||
|
<div className={styles.summaryRow}>
|
||||||
|
<span>Tarif Tiket ({booking.seats.length}x)</span>
|
||||||
|
<span>{formatCurrency(booking.schedule.price * booking.seats.length)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${styles.summaryRow} ${styles.totalRow}`}>
|
||||||
|
<span>Total Bayar</span>
|
||||||
|
<span className={styles.totalValue}>{formatCurrency(booking.totalPrice)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side: Action (Payment Selector / Ticket View) */}
|
||||||
|
<div className={`${styles.actionCard} glass-panel`}>
|
||||||
|
{status === 'PENDING' && timeLeft !== 'EXPIRED' ? (
|
||||||
|
paymentMethod === 'TUNAI' ? (
|
||||||
|
/* Tunai Instructions UI */
|
||||||
|
<div className={styles.paymentSimulator}>
|
||||||
|
<h3 className={styles.panelTitle}>Instruksi Pembayaran Tunai</h3>
|
||||||
|
<p className={styles.panelSubtitle}>Silakan selesaikan pembayaran Anda di loket/counter terdekat.</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px', marginTop: '20px', fontSize: '0.9rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<span style={{ background: 'var(--primary)', color: 'white', width: '24px', height: '24px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontWeight: 'bold' }}>1</span>
|
||||||
|
<p>Kunjungi loket/counter resmi terdekat kami.</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<span style={{ background: 'var(--primary)', color: 'white', width: '24px', height: '24px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontWeight: 'bold' }}>2</span>
|
||||||
|
<p>Sebutkan Kode Booking Anda kepada petugas loket: <strong style={{ color: 'var(--primary)' }}>{booking.bookingCode}</strong></p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<span style={{ background: 'var(--primary)', color: 'white', width: '24px', height: '24px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontWeight: 'bold' }}>3</span>
|
||||||
|
<p>Lakukan pembayaran tunai sebesar <strong style={{ color: 'var(--text-white)' }}>{formatCurrency(booking.totalPrice)}</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<span style={{ background: 'var(--primary)', color: 'white', width: '24px', height: '24px', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontWeight: 'bold' }}>4</span>
|
||||||
|
<p>Setelah petugas mengonfirmasi pembayaran, tiket digital Anda akan otomatis aktif.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '25px', padding: '12px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', border: '1px solid var(--border-light)', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||||
|
Butuh bantuan? Silakan hubungi CS kami via WhatsApp di <strong>{settings?.csWhatsapp || '+62 812-3456-7890'}</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetMethod}
|
||||||
|
className="btn"
|
||||||
|
style={{ width: '100%', marginTop: '20px', border: '1px solid var(--border-light)', backgroundColor: 'var(--primary)', color: 'var(--text-white)' }}
|
||||||
|
>
|
||||||
|
<FaSync style={{ marginRight: '6px' }} /> Ubah Metode Pembayaran
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : paymentMethod === 'QRIS' ? (
|
||||||
|
/* QRIS Waiting Screen */
|
||||||
|
<div className={styles.paymentSimulator}>
|
||||||
|
<h3 className={styles.panelTitle}>Pembayaran QRIS</h3>
|
||||||
|
<p className={styles.panelSubtitle}>Scan kode QRIS di bawah ini dengan aplikasi pembayaran Anda.</p>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '16px', margin: '20px 0', textAlign: 'center' }}>
|
||||||
|
{loadingQris ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px', padding: '40px 0' }}>
|
||||||
|
<div className={styles.spinner} style={{ width: '40px', height: '40px', border: '3px solid var(--border-light)', borderTopColor: 'var(--primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }}></div>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Membuat kode QRIS pembayaran...</p>
|
||||||
|
</div>
|
||||||
|
) : qrisString ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<div style={{ background: 'white', padding: '16px', borderRadius: '12px', boxShadow: '0 10px 25px rgba(0,0,0,0.3)', display: 'inline-block' }}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(qrisString)}`}
|
||||||
|
alt="QRIS Code"
|
||||||
|
style={{ width: '240px', height: '240px', display: 'block' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)', maxWidth: '280px', lineHeight: '1.4' }}>
|
||||||
|
Mendukung: GoPay, OVO, ShopeePay, Dana, LinkAja, BCA Mobile, Mandiri Livin, dan e-wallet/m-banking berstandar QRIS lainnya.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '20px', color: 'var(--accent-rose)', fontSize: '0.9rem' }}>
|
||||||
|
Gagal menghasilkan QRIS. Harap segarkan halaman atau pilih metode lain.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px', marginBottom: '20px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||||||
|
<div className={styles.spinner} style={{ width: '14px', height: '14px', border: '2px solid var(--border-light)', borderTopColor: 'var(--primary)', borderRadius: '50%', animation: 'spin 1s linear infinite' }}></div>
|
||||||
|
<span>Mengecek status pembayaran...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => checkPakasirStatus(true)}
|
||||||
|
disabled={checkingStatus}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
{checkingStatus ? 'Memeriksa...' : <><FaSync style={{ marginRight: '6px' }} /> Cek Status Pembayaran</>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResetMethod}
|
||||||
|
className="btn"
|
||||||
|
style={{ width: '100%', border: '1px solid var(--border-light)', color: 'var(--text-white)', background: 'transparent' }}
|
||||||
|
>
|
||||||
|
<FaSync style={{ marginRight: '6px' }} /> Ubah Metode Pembayaran
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Select payment method UI */
|
||||||
|
<div className={styles.paymentSimulator}>
|
||||||
|
<h3 className={styles.panelTitle}>Pilih Metode Pembayaran</h3>
|
||||||
|
<p className={styles.panelSubtitle}>Silakan pilih opsi pembayaran di bawah ini untuk menyelesaikan transaksi.</p>
|
||||||
|
|
||||||
|
{error && <div className={styles.errorAlert}>{error}</div>}
|
||||||
|
|
||||||
|
<div className={styles.paymentMethods}>
|
||||||
|
<label className={`${styles.methodLabel} ${selectedMethod === 'QRIS' ? styles.selectedMethod : ''}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="payment"
|
||||||
|
value="QRIS"
|
||||||
|
checked={selectedMethod === 'QRIS'}
|
||||||
|
onChange={() => setSelectedMethod('QRIS')}
|
||||||
|
className={styles.radioInput}
|
||||||
|
/>
|
||||||
|
<span className={styles.methodIcon}><FaMobileAlt /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Scan QRIS (Otomatis)</strong>
|
||||||
|
<span className={styles.methodDesc}>Bayar instan via Aplikasi M-Banking / E-Wallet</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className={`${styles.methodLabel} ${selectedMethod === 'TUNAI' ? styles.selectedMethod : ''}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="payment"
|
||||||
|
value="TUNAI"
|
||||||
|
checked={selectedMethod === 'TUNAI'}
|
||||||
|
onChange={() => setSelectedMethod('TUNAI')}
|
||||||
|
className={styles.radioInput}
|
||||||
|
/>
|
||||||
|
<span className={styles.methodIcon}><FaMoneyBillWave /></span>
|
||||||
|
<div>
|
||||||
|
<strong>Bayar Tunai</strong>
|
||||||
|
<span className={styles.methodDesc}>Bayar langsung di loket/counter resmi terdekat</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={selectedMethod === 'QRIS' ? handleSelectQris : handleSelectTunai}
|
||||||
|
disabled={isPaying}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%', marginTop: '20px' }}
|
||||||
|
>
|
||||||
|
{isPaying ? 'Memproses Transaksi...' : selectedMethod === 'QRIS' ? 'Lanjutkan ke Pembayaran QRIS' : 'Pilih Pembayaran Tunai'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : status === 'PAID' ? (
|
||||||
|
/* PAID ticket access link */
|
||||||
|
<div className={styles.ticketSection}>
|
||||||
|
<div className={styles.successIcon}><FaGift /></div>
|
||||||
|
<h3>Terima Kasih!</h3>
|
||||||
|
<p>Pembayaran Anda telah diterima dengan sukses. Tiket Anda siap digunakan untuk perjalanan di {brandName}.</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/ticket/${booking.bookingCode}`}
|
||||||
|
className="btn btn-success"
|
||||||
|
style={{ width: '100%', marginTop: '30px', fontWeight: 'bold' }}
|
||||||
|
>
|
||||||
|
<FaTicketAlt style={{ marginRight: '6px' }} /> Lihat Tiket Digital (QR Code)
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Expired/Cancelled State */
|
||||||
|
<div className={styles.expiredSection}>
|
||||||
|
<div className={styles.expiredIcon}><FaTimesCircle /></div>
|
||||||
|
<h3>Booking Tidak Aktif</h3>
|
||||||
|
<p>Pemesanan ini telah kedaluwarsa atau dibatalkan. Silakan kembali ke halaman utama untuk membuat pesanan baru.</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ width: '100%', marginTop: '30px' }}
|
||||||
|
>
|
||||||
|
Kembali ke Beranda
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
.footer {
|
||||||
|
background: var(--bg-deep);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding: 60px 0 30px 0;
|
||||||
|
margin-top: 80px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1.2fr;
|
||||||
|
gap: 40px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandSection .logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoHighlight {
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandDesc {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkSection h4 {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkList {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkList li {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkList li:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactInfo {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contactInfo strong {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottomBar {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.bottomBar {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.socials {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.socials span {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.socials span:hover {
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { FaBus, FaFacebookF, FaInstagram, FaTwitter } from 'react-icons/fa';
|
||||||
|
import styles from './Footer.module.css';
|
||||||
|
|
||||||
|
export default function Footer({ settings }: { settings?: any }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Hide on admin routes
|
||||||
|
if (pathname.startsWith('/admin')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandName = settings?.brandName || 'AntarKota';
|
||||||
|
const logoIcon = settings?.logoIcon || null;
|
||||||
|
const logoHighlight = settings?.logoHighlight || 'Kota';
|
||||||
|
const csPhone = settings?.csPhone || '0804-1-808-808';
|
||||||
|
const csWhatsapp = settings?.csWhatsapp || '+62 812-3456-7890';
|
||||||
|
const csEmail = settings?.csEmail || 'support@antarkota.com';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<div className={styles.brandSection}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
{settings?.logoImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={settings.logoImageUrl} alt={brandName} className={styles.uploadedLogo} style={{ height: '32px', marginRight: '10px', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.logoIcon}>{logoIcon || <FaBus />}</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.logoText}>
|
||||||
|
{brandName.endsWith(logoHighlight) ? (
|
||||||
|
<>
|
||||||
|
{brandName.slice(0, -logoHighlight.length)}
|
||||||
|
<span className={styles.logoHighlight}>{logoHighlight}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
brandName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.brandDesc}>
|
||||||
|
Penyedia layanan perjalanan antarkota premium yang menghubungkan Anda dengan kota-kota utama secara cepat, aman, dan nyaman di {brandName}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.linkSection}>
|
||||||
|
<h4>Destinasi Populer</h4>
|
||||||
|
<ul className={styles.linkList}>
|
||||||
|
<li>Jakarta - Bandung</li>
|
||||||
|
<li>Bandung - Jakarta</li>
|
||||||
|
<li>Jakarta - Bogor</li>
|
||||||
|
<li>Bogor - Jakarta</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.linkSection}>
|
||||||
|
<h4>Hubungi Kami</h4>
|
||||||
|
<p className={styles.contactInfo}>Call Center: <strong>{csPhone}</strong></p>
|
||||||
|
<p className={styles.contactInfo}>WhatsApp: <strong>{csWhatsapp}</strong></p>
|
||||||
|
<p className={styles.contactInfo}>Email: {csEmail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.bottomBar}>
|
||||||
|
<p>© {new Date().getFullYear()} {brandName} Travel. Hak Cipta Dilindungi.</p>
|
||||||
|
<div className={styles.socials}>
|
||||||
|
<span><FaFacebookF /></span>
|
||||||
|
<span><FaInstagram /></span>
|
||||||
|
<span><FaTwitter /></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
.header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-header, color-mix(in srgb, var(--primary) 7%, rgba(4, 5, 10, 0.7)));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
height: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoHighlight {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
background: var(--bg-navlink-hover, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.navLink.active {
|
||||||
|
color: var(--text-white);
|
||||||
|
background: var(--bg-navlink-hover, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminBadge {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--text-white);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adminBadge:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-white);
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.authActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.authLinks {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginLink {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loginLink:hover {
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registerBtn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-on-primary, var(--text-white));
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.registerBtn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userMenu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutBtn:hover {
|
||||||
|
background: var(--accent-rose);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive navigation */
|
||||||
|
.burgerBtn {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 24px;
|
||||||
|
height: 18px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 101;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burgerLine {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: var(--text-white);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.burgerLineOpen:nth-child(1) {
|
||||||
|
transform: translateY(8px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.burgerLineOpen:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burgerLineOpen:nth-child(3) {
|
||||||
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.burgerBtn {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav,
|
||||||
|
.authActions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileMenu {
|
||||||
|
position: absolute;
|
||||||
|
top: 70px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: rgb(from var(--text-primary) calc(255 - r) calc(255 - g) calc(255 - b));
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 99;
|
||||||
|
animation: slideDown 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileNav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileNavLink {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileNavLink:hover,
|
||||||
|
.mobileNavLink.active {
|
||||||
|
color: var(--text-white);
|
||||||
|
background: var(--bg-navlink-hover, rgba(255, 255, 255, 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileAdminBadge {
|
||||||
|
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||||
|
color: var(--text-white);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileDivider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-light);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileUserMenu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileUserName {
|
||||||
|
color: var(--text-white);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileLogoutBtn {
|
||||||
|
background: rgba(244, 63, 94, 0.1);
|
||||||
|
color: var(--accent-rose);
|
||||||
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileLogoutBtn:hover {
|
||||||
|
background: var(--accent-rose);
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileAuthLinks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileLoginLink {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileRegisterBtn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-on-primary, var(--text-white));
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-glow);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { FaBus } from 'react-icons/fa';
|
||||||
|
import styles from './Header.module.css';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ settings }: { settings?: any }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Fetch session user on mount or pathname change
|
||||||
|
useEffect(() => {
|
||||||
|
async function checkSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/session');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUser(data.user);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkSession();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Don't render customer header on admin routes
|
||||||
|
if (pathname.startsWith('/admin')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
setUser(null);
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Logout error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const brandName = settings?.brandName || 'AntarKota';
|
||||||
|
const logoIcon = settings?.logoIcon || null;
|
||||||
|
const logoHighlight = settings?.logoHighlight || 'Kota';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<div className={styles.container}>
|
||||||
|
<Link href="/" className={styles.logo} onClick={() => setIsOpen(false)}>
|
||||||
|
{settings?.logoImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={settings.logoImageUrl} alt={brandName} className={styles.uploadedLogo} style={{ height: '32px', marginRight: '10px', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.logoIcon}>{logoIcon || <FaBus />}</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.logoText}>
|
||||||
|
{brandName.endsWith(logoHighlight) ? (
|
||||||
|
<>
|
||||||
|
{brandName.slice(0, -logoHighlight.length)}
|
||||||
|
<span className={styles.logoHighlight}>{logoHighlight}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
brandName
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
<Link href="/" className={`${styles.navLink} ${pathname === '/' ? styles.active : ''}`}>
|
||||||
|
Cari Tiket
|
||||||
|
</Link>
|
||||||
|
<Link href="/ticket-check" className={`${styles.navLink} ${pathname === '/ticket-check' ? styles.active : ''}`}>
|
||||||
|
Cek Pemesanan
|
||||||
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<Link href="/dashboard" className={`${styles.navLink} ${pathname === '/dashboard' ? styles.active : ''}`}>
|
||||||
|
Dashboard Saya
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{user?.role === 'ADMIN' && (
|
||||||
|
<Link href="/admin" className={styles.adminBadge}>
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={styles.authActions}>
|
||||||
|
{user ? (
|
||||||
|
<div className={styles.userMenu}>
|
||||||
|
<Link href="/dashboard" className={styles.userName} style={{ color: 'var(--text-white)', fontWeight: 600, textDecoration: 'none' }}>
|
||||||
|
Halo, {user.name}
|
||||||
|
</Link>
|
||||||
|
<button onClick={handleLogout} className={styles.logoutBtn}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.authLinks}>
|
||||||
|
<Link href="/login" className={styles.loginLink}>
|
||||||
|
Masuk
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className={styles.registerBtn}>
|
||||||
|
Daftar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className={styles.burgerBtn} onClick={() => setIsOpen(!isOpen)} aria-label="Toggle Menu">
|
||||||
|
<span className={`${styles.burgerLine} ${isOpen ? styles.burgerLineOpen : ''}`}></span>
|
||||||
|
<span className={`${styles.burgerLine} ${isOpen ? styles.burgerLineOpen : ''}`}></span>
|
||||||
|
<span className={`${styles.burgerLine} ${isOpen ? styles.burgerLineOpen : ''}`}></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className={styles.mobileMenu}>
|
||||||
|
<nav className={styles.mobileNav}>
|
||||||
|
<Link href="/" className={`${styles.mobileNavLink} ${pathname === '/' ? styles.active : ''}`} onClick={() => setIsOpen(false)}>
|
||||||
|
Cari Tiket
|
||||||
|
</Link>
|
||||||
|
<Link href="/ticket-check" className={`${styles.mobileNavLink} ${pathname === '/ticket-check' ? styles.active : ''}`} onClick={() => setIsOpen(false)}>
|
||||||
|
Cek Pemesanan
|
||||||
|
</Link>
|
||||||
|
{user && (
|
||||||
|
<Link href="/dashboard" className={`${styles.mobileNavLink} ${pathname === '/dashboard' ? styles.active : ''}`} onClick={() => setIsOpen(false)}>
|
||||||
|
Dashboard Saya
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{user?.role === 'ADMIN' && (
|
||||||
|
<Link href="/admin" className={styles.mobileAdminBadge} onClick={() => setIsOpen(false)}>
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.mobileDivider}></div>
|
||||||
|
|
||||||
|
{user ? (
|
||||||
|
<div className={styles.mobileUserMenu}>
|
||||||
|
<span className={styles.mobileUserName}>Halo, {user.name}</span>
|
||||||
|
<button onClick={() => { handleLogout(); setIsOpen(false); }} className={styles.mobileLogoutBtn}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.mobileAuthLinks}>
|
||||||
|
<Link href="/login" className={styles.mobileLoginLink} onClick={() => setIsOpen(false)}>
|
||||||
|
Masuk
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className={styles.mobileRegisterBtn} onClick={() => setIsOpen(false)}>
|
||||||
|
Daftar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
.widget {
|
||||||
|
padding: 30px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.widget {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr 1fr 0.8fr;
|
||||||
|
align-items: end;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapBtnContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapBtnContainer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 50px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapBtn {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-white);
|
||||||
|
transition: var(--transition-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swapBtn:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--text-white);
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submitBtn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorText {
|
||||||
|
color: var(--accent-rose);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { HiArrowsRightLeft } from 'react-icons/hi2';
|
||||||
|
import { FiSearch } from 'react-icons/fi';
|
||||||
|
import styles from './SearchWidget.module.css';
|
||||||
|
|
||||||
|
interface Route {
|
||||||
|
id: number;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchWidgetProps {
|
||||||
|
departureCities: string[];
|
||||||
|
arrivalCities: string[];
|
||||||
|
routes: Route[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchWidget({ departureCities, arrivalCities, routes }: SearchWidgetProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Find first available combination
|
||||||
|
const defaultFrom = departureCities[0] || '';
|
||||||
|
const defaultTo = arrivalCities.find(c => c !== defaultFrom) || arrivalCities[0] || '';
|
||||||
|
|
||||||
|
const [from, setFrom] = useState(defaultFrom);
|
||||||
|
const [to, setTo] = useState(defaultTo);
|
||||||
|
|
||||||
|
// Format today's date as YYYY-MM-DD
|
||||||
|
const todayStr = new Date().toISOString().split('T')[0];
|
||||||
|
const [date, setDate] = useState(todayStr);
|
||||||
|
const [passengers, setPassengers] = useState(1);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!from || !to) {
|
||||||
|
setError('Silakan pilih kota asal dan tujuan.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (from === to) {
|
||||||
|
setError('Kota asal dan tujuan tidak boleh sama.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Redirect to search results page
|
||||||
|
router.push(`/search?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&date=${date}&passengers=${passengers}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSearch} className={`${styles.widget} glass-panel`}>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Asal Keberangkatan</label>
|
||||||
|
<select
|
||||||
|
value={from}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
style={{ appearance: 'none', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 16px center' }}
|
||||||
|
>
|
||||||
|
<option value="">Pilih Asal</option>
|
||||||
|
{departureCities.map((city) => (
|
||||||
|
<option key={city} value={city}>{city}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.swapBtnContainer}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const temp = from;
|
||||||
|
setFrom(to);
|
||||||
|
setTo(temp);
|
||||||
|
}}
|
||||||
|
className={styles.swapBtn}
|
||||||
|
title="Tukar Asal dan Tujuan"
|
||||||
|
>
|
||||||
|
<HiArrowsRightLeft />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Kota Tujuan</label>
|
||||||
|
<select
|
||||||
|
value={to}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
<option value="">Pilih Tujuan</option>
|
||||||
|
{arrivalCities.map((city) => (
|
||||||
|
<option key={city} value={city}>{city}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Tanggal Perjalanan</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
min={todayStr}
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
className="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Jumlah Penumpang</label>
|
||||||
|
<select
|
||||||
|
value={passengers}
|
||||||
|
onChange={(e) => setPassengers(Number(e.target.value))}
|
||||||
|
className="form-input"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<option key={n} value={n}>{n} Orang</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.errorText}>{error}</p>}
|
||||||
|
|
||||||
|
<button type="submit" className={`btn btn-primary ${styles.submitBtn}`}>
|
||||||
|
<FiSearch style={{ marginRight: '6px' }} /> Cari Keberangkatan
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketCard {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketHeader {
|
||||||
|
padding: 24px 30px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketLayout {
|
||||||
|
display: flex;
|
||||||
|
min-height: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ticketLayout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainInfo {
|
||||||
|
flex: 2;
|
||||||
|
padding: 30px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeSection {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowSection {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
height: 2px;
|
||||||
|
background: var(--border-light);
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before,
|
||||||
|
.line::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before {
|
||||||
|
left: 0;
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::after {
|
||||||
|
right: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vehicleType {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsGrid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stub Styling */
|
||||||
|
.stub {
|
||||||
|
flex: 0.8;
|
||||||
|
padding: 30px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-left: 2px dashed var(--border-light);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-align: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stub {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 2px dashed var(--border-light);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.seats {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white)
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrCodeContainer {
|
||||||
|
background: white;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrCode {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-white);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
padding: 20px 30px;
|
||||||
|
background: color-mix(in srgb, var(--accent-amber) 3%, transparent);
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Responsiveness for Tickets */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mainInfo {
|
||||||
|
padding: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.ticketHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.actionsBar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionsBar button,
|
||||||
|
.actionsBar a,
|
||||||
|
.actionsBar .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.routeSection {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrowSection {
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 15px;
|
||||||
|
bottom: 15px;
|
||||||
|
height: auto;
|
||||||
|
width: 2px;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
width: 2px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before,
|
||||||
|
.line::after {
|
||||||
|
left: -3px;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line::after {
|
||||||
|
bottom: 0;
|
||||||
|
top: auto;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration,
|
||||||
|
.vehicleType {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailsGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print CSS Styles */
|
||||||
|
@media print {
|
||||||
|
|
||||||
|
/* Hide page headers, footers and printer buttons */
|
||||||
|
.actionsBar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketCard {
|
||||||
|
background: white !important;
|
||||||
|
color: black !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticketHeader {
|
||||||
|
background: white !important;
|
||||||
|
border-bottom: 1px solid #ccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h3 {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city,
|
||||||
|
.time,
|
||||||
|
.value,
|
||||||
|
.seats,
|
||||||
|
.code {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
background: #ccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stub {
|
||||||
|
background: white !important;
|
||||||
|
border-left: 2px dashed #ccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrCodeContainer {
|
||||||
|
box-shadow: none !important;
|
||||||
|
border: 1px solid #ccc !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaPrint, FaHome, FaBus, FaExclamationTriangle } from 'react-icons/fa';
|
||||||
|
import styles from './TicketView.module.css';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
bookingCode: string;
|
||||||
|
passengerName: string;
|
||||||
|
passengerEmail: string;
|
||||||
|
passengerPhone: string;
|
||||||
|
totalPrice: number;
|
||||||
|
status: string;
|
||||||
|
paymentMethod: string | null;
|
||||||
|
paymentTime: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
seats: string[];
|
||||||
|
schedule: {
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
vehicleType: string;
|
||||||
|
departureCity: string;
|
||||||
|
arrivalCity: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TicketView({ booking, settings }: { booking: Booking; settings?: any }) {
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr: string) => {
|
||||||
|
const d = new Date(timeStr);
|
||||||
|
return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
window.print();
|
||||||
|
};
|
||||||
|
|
||||||
|
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&color=070913&data=${encodeURIComponent(booking.bookingCode)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.actionsBar}>
|
||||||
|
<button onClick={handlePrint} className="btn btn-primary">
|
||||||
|
<FaPrint style={{ marginRight: '6px' }} /> Cetak Tiket (PDF)
|
||||||
|
</button>
|
||||||
|
<Link href="/" className="btn btn-secondary">
|
||||||
|
<FaHome style={{ marginRight: '6px' }} /> Kembali ke Beranda
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Card Container */}
|
||||||
|
<div className={`${styles.ticketCard} glass-panel`}>
|
||||||
|
{/* Boarding Pass Header */}
|
||||||
|
<div className={styles.ticketHeader}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
{settings?.logoImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={settings.logoImageUrl} alt="Logo" style={{ height: '32px', marginRight: '10px', objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<span className={styles.logoIcon}>{settings?.logoIcon || <FaBus />}</span>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h3>{(settings?.brandName || 'AntarKota') + ' Travel'}</h3>
|
||||||
|
<span>E-TICKET / BOARDING PASS</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.ticketStatus}>
|
||||||
|
<span className={`badge ${booking.status === 'PAID' ? 'badge-paid' : 'badge-pending'}`}>
|
||||||
|
{booking.status === 'PAID' ? 'LUNAS / ACTIVE' : 'PENDING'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className={styles.ticketLayout}>
|
||||||
|
{/* Main Info Side */}
|
||||||
|
<div className={styles.mainInfo}>
|
||||||
|
<div className={styles.routeSection}>
|
||||||
|
<div className={styles.station}>
|
||||||
|
<span className={styles.label}>KOTA ASAL</span>
|
||||||
|
<span className={styles.city}>{booking.schedule.departureCity}</span>
|
||||||
|
<span className={styles.time}>{formatTime(booking.schedule.departureTime)} WIB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.arrowSection}>
|
||||||
|
<span className={styles.duration}>
|
||||||
|
{Math.floor(booking.schedule.durationMinutes / 60)}j {booking.schedule.durationMinutes % 60}m
|
||||||
|
</span>
|
||||||
|
<div className={styles.line}></div>
|
||||||
|
<span className={styles.vehicleType}>{booking.schedule.vehicleType}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.station} style={{ textAlign: 'right' }}>
|
||||||
|
<span className={styles.label}>KOTA TUJUAN</span>
|
||||||
|
<span className={styles.city}>{booking.schedule.arrivalCity}</span>
|
||||||
|
<span className={styles.time}>{formatTime(booking.schedule.arrivalTime)} WIB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.detailsGrid}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>NAMA PENUMPANG</span>
|
||||||
|
<span className={styles.value}>{booking.passengerName}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>TANGGAL PERJALANAN</span>
|
||||||
|
<span className={styles.value}>{formatDate(booking.schedule.departureTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>KONTAK WHATSAPP</span>
|
||||||
|
<span className={styles.value}>{booking.passengerPhone}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className={styles.label}>TOTAL TARIF</span>
|
||||||
|
<span className={styles.value} style={{ color: 'var(--text-white)', fontWeight: 'bold' }}>
|
||||||
|
{formatCurrency(booking.totalPrice)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ticket Stub / QR Code Side */}
|
||||||
|
<div className={styles.stub}>
|
||||||
|
<div className={styles.stubDetails}>
|
||||||
|
<span className={styles.label}>NOMOR KURSI</span>
|
||||||
|
<span className={styles.seats}>{booking.seats.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.qrCodeContainer}>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={qrCodeUrl}
|
||||||
|
alt={`QR Code Booking ${booking.bookingCode}`}
|
||||||
|
className={styles.qrCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.bookingCodeText}>
|
||||||
|
<span className={styles.label}>KODE BOOKING</span>
|
||||||
|
<span className={styles.code}>{booking.bookingCode}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes/Disclaimers */}
|
||||||
|
<div className={styles.disclaimer}>
|
||||||
|
<p><FaExclamationTriangle style={{ marginRight: '6px', color: 'var(--accent-amber)' }} /> <strong>Catatan:</strong> Tunjukkan tiket digital ini atau sebutkan kode booking kepada petugas shuttle saat boarding minimal 15 menit sebelum keberangkatan.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(
|
||||||
|
process.env.JWT_SECRET || 'super-secret-key-change-me-in-production-1234567890'
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface JWTPayload {
|
||||||
|
userId: number;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signJWT(payload: JWTPayload): Promise<string> {
|
||||||
|
return new SignJWT({ ...payload })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('24h')
|
||||||
|
.sign(JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyJWT(token: string): Promise<JWTPayload | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||||
|
return payload as unknown as JWTPayload;
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionUser(): Promise<JWTPayload | null> {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const token = cookieStore.get('auth_token')?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
return verifyJWT(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin(): Promise<JWTPayload> {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user || user.role !== 'ADMIN') {
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var prisma: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
const adapter = new PrismaMariaDb({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USERNAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
connectionLimit: 10,
|
||||||
|
});
|
||||||
|
prisma = new PrismaClient({ adapter });
|
||||||
|
} else {
|
||||||
|
if (!global.prisma) {
|
||||||
|
const adapter = new PrismaMariaDb({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: Number(process.env.DB_PORT),
|
||||||
|
user: process.env.DB_USERNAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
connectionLimit: 5,
|
||||||
|
});
|
||||||
|
global.prisma = new PrismaClient({ adapter });
|
||||||
|
}
|
||||||
|
prisma = global.prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { prisma };
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { prisma } from './db';
|
||||||
|
import { getSettings } from './settings';
|
||||||
|
|
||||||
|
export async function sendTicketEmail(bookingId: number) {
|
||||||
|
try {
|
||||||
|
// 1. Fetch active settings
|
||||||
|
const settings = await getSettings();
|
||||||
|
const {
|
||||||
|
brandName,
|
||||||
|
logoImageUrl,
|
||||||
|
logoIcon,
|
||||||
|
primaryColor,
|
||||||
|
smtpHost,
|
||||||
|
smtpPort,
|
||||||
|
smtpUser,
|
||||||
|
smtpPassword,
|
||||||
|
smtpSenderName,
|
||||||
|
smtpSenderEmail,
|
||||||
|
csPhone,
|
||||||
|
csWhatsapp,
|
||||||
|
csEmail,
|
||||||
|
} = settings;
|
||||||
|
|
||||||
|
// 2. Guard: Verify if SMTP is configured
|
||||||
|
if (!smtpHost || !smtpUser || !smtpPassword) {
|
||||||
|
console.warn(`[SMTP Email] SMTP is not fully configured (host: "${smtpHost}", user: "${smtpUser}"). Skipping email notification.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fetch Booking with related Schedule and Route
|
||||||
|
const booking = await prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: {
|
||||||
|
seats: true,
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
route: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
console.error(`[SMTP Email] Booking with ID ${bookingId} not found.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!booking.passengerEmail) {
|
||||||
|
console.warn(`[SMTP Email] Passenger email is missing for booking ${booking.bookingCode}.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Set up nodemailer transporter
|
||||||
|
const port = Number(smtpPort) || 587;
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: smtpHost,
|
||||||
|
port: port,
|
||||||
|
secure: port === 465, // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Formats
|
||||||
|
const formatCurrency = (val: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (date: Date) => {
|
||||||
|
return date.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
|
||||||
|
const ticketUrl = `${appUrl}/ticket/${booking.bookingCode}`;
|
||||||
|
const brandColorHex = primaryColor || '#6366f1';
|
||||||
|
|
||||||
|
// 6. Build HTML template with brand color matching theme settings
|
||||||
|
const htmlContent = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>E-Tiket Perjalanan Anda - ${booking.bookingCode}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: #0b0f19;
|
||||||
|
color: #e2e8f0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.email-wrapper {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 30px auto;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.email-header {
|
||||||
|
background: linear-gradient(135deg, ${brandColorHex} 0%, #0f172a 100%);
|
||||||
|
padding: 30px 40px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.brand-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
.email-body {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.greeting {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.intro-text {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.ticket-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.code-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.code-value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: ${brandColorHex};
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.grid-row {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.grid-col {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.route-flow {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
.city-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.time-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: ${brandColorHex};
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.seat-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.btn-container {
|
||||||
|
text-align: center;
|
||||||
|
margin: 35px 0 15px 0;
|
||||||
|
}
|
||||||
|
.btn-action {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: ${brandColorHex};
|
||||||
|
color: #ffffff !important;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 14px 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 4px 14px color-mix(in srgb, ${brandColorHex} 30%, transparent);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.email-footer {
|
||||||
|
background: #020617;
|
||||||
|
padding: 30px 40px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.footer-links {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.footer-link {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="email-wrapper">
|
||||||
|
<div class="email-header">
|
||||||
|
<h1 class="brand-title">
|
||||||
|
<span class="brand-logo">${logoIcon}</span>
|
||||||
|
${brandName}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-body">
|
||||||
|
<p class="greeting">Halo ${booking.passengerName},</p>
|
||||||
|
<p class="intro-text">
|
||||||
|
Pembayaran Anda untuk pemesanan tiket perjalanan telah berhasil dikonfirmasi. Berikut adalah rincian e-ticket resmi Anda:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="ticket-panel">
|
||||||
|
<div class="code-label">Kode Booking Perjalanan</div>
|
||||||
|
<div class="code-value">${booking.bookingCode}</div>
|
||||||
|
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-col">
|
||||||
|
<div class="info-label">Nama Penumpang</div>
|
||||||
|
<div class="info-value">${booking.passengerName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-col">
|
||||||
|
<div class="info-label">Kursi Terpilih</div>
|
||||||
|
<div class="info-value">
|
||||||
|
${booking.seats.map(s => `<span class="seat-badge">${s.seatNumber}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-row">
|
||||||
|
<div class="grid-col">
|
||||||
|
<div class="info-label">Jenis Armada</div>
|
||||||
|
<div class="info-value">${booking.schedule.vehicleType}</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-col">
|
||||||
|
<div class="info-label">Total Pembayaran</div>
|
||||||
|
<div class="info-value" style="color: #10b981;">${formatCurrency(Number(booking.totalPrice))}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="route-flow">
|
||||||
|
<div class="grid-row" style="margin-bottom: 0;">
|
||||||
|
<div class="grid-col">
|
||||||
|
<div class="info-label">Keberangkatan</div>
|
||||||
|
<div class="city-title">${booking.schedule.route.departureCity}</div>
|
||||||
|
<div class="time-sub">${formatTime(booking.schedule.departureTime)}</div>
|
||||||
|
<div style="font-size: 12px; color: #94a3b8; margin-top: 2px;">${formatDate(booking.schedule.departureTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-col" style="text-align: center; display: flex; align-items: center; justify-content: center; max-width: 50px;">
|
||||||
|
<span style="font-size: 20px; color: #64748b;">➔</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid-col" style="text-align: right;">
|
||||||
|
<div class="info-label">Tujuan</div>
|
||||||
|
<div class="city-title">${booking.schedule.route.arrivalCity}</div>
|
||||||
|
<div class="time-sub">${formatTime(booking.schedule.arrivalTime)}</div>
|
||||||
|
<div style="font-size: 12px; color: #94a3b8; margin-top: 2px;">${formatDate(booking.schedule.arrivalTime)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="intro-text" style="text-align: center;">
|
||||||
|
Silakan bawa Kode Booking atau tunjukkan boarding pass digital Anda saat keberangkatan di lokasi loket resmi kami.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="btn-container">
|
||||||
|
<a href="${ticketUrl}" target="_blank" class="btn-action">Lihat Boarding Pass Digital</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="email-footer">
|
||||||
|
<p>Terima kasih telah bepergian bersama kami!</p>
|
||||||
|
<p>Butuh bantuan? CS Phone: ${csPhone} | WhatsApp: ${csWhatsapp} | Email: ${csEmail}</p>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="${appUrl}" class="footer-link">Beranda</a> |
|
||||||
|
<a href="${appUrl}/dashboard" class="footer-link">Dashboard Saya</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 7. Send the email
|
||||||
|
const senderName = smtpSenderName || brandName;
|
||||||
|
const senderEmail = smtpSenderEmail || smtpUser;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"${senderName}" <${senderEmail}>`,
|
||||||
|
to: booking.passengerEmail,
|
||||||
|
subject: `[E-Tiket Lunas] Boarding Pass Perjalanan Anda - ${booking.bookingCode}`,
|
||||||
|
html: htmlContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SMTP Email] Email receipt successfully sent to ${booking.passengerEmail} for booking ${booking.bookingCode}.`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SMTP Email] Error sending email receipt:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { prisma } from './db';
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS = {
|
||||||
|
brandName: 'AntarKota',
|
||||||
|
logoIcon: '🚌',
|
||||||
|
logoHighlight: 'Kota',
|
||||||
|
logoImageUrl: '',
|
||||||
|
primaryColor: '#6366f1',
|
||||||
|
csPhone: '0804-1-808-808',
|
||||||
|
csWhatsapp: '+62 812-3456-7890',
|
||||||
|
csEmail: 'support@antarkota.com',
|
||||||
|
pakasirSlug: 'travel-antarkota',
|
||||||
|
pakasirApiKey: '',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: '587',
|
||||||
|
smtpUser: '',
|
||||||
|
smtpPassword: '',
|
||||||
|
smtpSenderName: 'AntarKota Travel',
|
||||||
|
smtpSenderEmail: 'noreply@antarkota.com',
|
||||||
|
vehicleLayouts: JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'Toyota HiAce (10 Seater)',
|
||||||
|
rows: 4,
|
||||||
|
cols: 3,
|
||||||
|
capacity: 10,
|
||||||
|
seats: [
|
||||||
|
{ row: 0, col: 2, label: '1' },
|
||||||
|
{ row: 1, col: 0, label: '2' },
|
||||||
|
{ row: 1, col: 1, label: '3' },
|
||||||
|
{ row: 1, col: 2, label: '4' },
|
||||||
|
{ row: 2, col: 0, label: '5' },
|
||||||
|
{ row: 2, col: 1, label: '6' },
|
||||||
|
{ row: 2, col: 2, label: '7' },
|
||||||
|
{ row: 3, col: 0, label: '8' },
|
||||||
|
{ row: 3, col: 1, label: '9' },
|
||||||
|
{ row: 3, col: 2, label: '10' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Executive Bus (30 Seater)',
|
||||||
|
rows: 8,
|
||||||
|
cols: 5,
|
||||||
|
capacity: 30,
|
||||||
|
seats: [
|
||||||
|
{ row: 0, col: 0, label: '1A' }, { row: 0, col: 1, label: '1B' }, { row: 0, col: 3, label: '1C' }, { row: 0, col: 4, label: '1D' },
|
||||||
|
{ row: 1, col: 0, label: '2A' }, { row: 1, col: 1, label: '2B' }, { row: 1, col: 3, label: '2C' }, { row: 1, col: 4, label: '2D' },
|
||||||
|
{ row: 2, col: 0, label: '3A' }, { row: 2, col: 1, label: '3B' }, { row: 2, col: 3, label: '3C' }, { row: 2, col: 4, label: '3D' },
|
||||||
|
{ row: 3, col: 0, label: '4A' }, { row: 3, col: 1, label: '4B' }, { row: 3, col: 3, label: '4C' }, { row: 3, col: 4, label: '4D' },
|
||||||
|
{ row: 4, col: 0, label: '5A' }, { row: 4, col: 1, label: '5B' }, { row: 4, col: 3, label: '5C' }, { row: 4, col: 4, label: '5D' },
|
||||||
|
{ row: 5, col: 0, label: '6A' }, { row: 5, col: 1, label: '6B' }, { row: 5, col: 3, label: '6C' }, { row: 5, col: 4, label: '6D' },
|
||||||
|
{ row: 6, col: 0, label: '7A' }, { row: 6, col: 1, label: '7B' }, { row: 6, col: 3, label: '7C' }, { row: 6, col: 4, label: '7D' },
|
||||||
|
{ row: 7, col: 0, label: '8A' }, { row: 7, col: 1, label: '8B' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSettings() {
|
||||||
|
try {
|
||||||
|
const dbSettings = await prisma.systemSetting.findMany();
|
||||||
|
const settings = { ...DEFAULT_SETTINGS };
|
||||||
|
|
||||||
|
for (const setting of dbSettings) {
|
||||||
|
if (setting.key in settings) {
|
||||||
|
(settings as any)[setting.key] = setting.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings from DB:', error);
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { jwtVerify } from 'jose';
|
||||||
|
|
||||||
|
const JWT_SECRET = new TextEncoder().encode(
|
||||||
|
process.env.JWT_SECRET || 'super-secret-key-change-me-in-production-1234567890'
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const path = request.nextUrl.pathname;
|
||||||
|
|
||||||
|
// Protect admin routes
|
||||||
|
if (path.startsWith('/admin')) {
|
||||||
|
const token = request.cookies.get('auth_token')?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// Redirect to login with callback
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
loginUrl.searchParams.set('callback', path);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||||
|
if (payload.role !== 'ADMIN') {
|
||||||
|
// Not an admin, redirect to homepage
|
||||||
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Invalid token, redirect to login
|
||||||
|
const loginUrl = new URL('/login', request.url);
|
||||||
|
loginUrl.searchParams.set('callback', path);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*'],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user