This commit is contained in:
Rio
2026-06-18 12:16:27 +07:00
parent 7de3a3b4b1
commit 5c0ab92401
84 changed files with 12562 additions and 285 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
.next
.git
.gitignore
*.md
.env
.env.*
docker-compose.yml
Dockerfile
+7
View File
@@ -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"
+2
View File
@@ -34,3 +34,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma
+42
View File
@@ -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"]
+49
View File
@@ -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:
+72
View File
@@ -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
View File
@@ -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;
+1171 -17
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -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"
} }
} }
+15
View File
@@ -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"],
},
});
+79
View File
@@ -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
View File
@@ -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

+223
View File
@@ -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;
}
}
+117
View File
@@ -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;
}
+257
View File
@@ -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 &amp; 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} &rarr; {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 &amp; 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>
);
}
+122
View File
@@ -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;
}
+162
View File
@@ -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>
);
}
+192
View File
@@ -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} &rarr; {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>
);
}
+289
View File
@@ -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}>
&times;
</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>
);
}
+157
View File
@@ -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;
}
+418
View File
@@ -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} &rarr; {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}>
&times;
</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} &rarr; {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;
}
+505
View File
@@ -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);
}
}
+394
View File
@@ -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>
);
}
+242
View File
@@ -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);
}
}
+46
View File
@@ -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 });
}
}
+40
View File
@@ -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 });
}
}
+49
View File
@@ -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 });
}
}
+45
View File
@@ -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 });
}
}
+64
View File
@@ -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 });
}
}
+72
View File
@@ -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 });
}
}
+94
View File
@@ -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 });
}
}
+70
View File
@@ -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 });
}
}
+46
View File
@@ -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 });
}
}
+62
View File
@@ -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 }
);
}
}
+11
View File
@@ -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;
}
+72
View File
@@ -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 }
);
}
}
+14
View File
@@ -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 });
}
}
+63
View File
@@ -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 });
}
}
+65
View File
@@ -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 });
}
}
+46
View File
@@ -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 });
}
}
+96
View File
@@ -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 }
);
}
}
+17
View File
@@ -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 });
}
}
+78
View File
@@ -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 }
);
}
}
+24
View File
@@ -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 });
}
}
+59
View File
@@ -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 });
}
}
+57
View File
@@ -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 });
}
}
+61
View File
@@ -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}
/>
);
}
+57
View File
@@ -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} />;
}
+369
View File
@@ -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;
}
}
+266
View File
@@ -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
View File
@@ -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;
} }
} }
+97 -20
View File
@@ -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 = {
title: "Create Next App",
description: "Generated by create next app",
}; };
}
export default function RootLayout({ function isLightColor(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);
// 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
}
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>
<style dangerouslySetInnerHTML={{
__html: themeVariables
}} />
</head>
<body>
<Header settings={settings} />
<main style={{ minHeight: 'calc(100vh - 70px - 340px)' }}>
{children} {children}
</main>
<Footer settings={settings} />
</body> </body>
</html> </html>
); );
} }
+134
View File
@@ -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;
}
}
+153
View File
@@ -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>
);
}
+86 -135
View File
@@ -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;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 32px; align-items: center;
grid-row-start: 2; text-align: center;
max-width: 1200px;
margin: 0 auto;
} }
.main ol { @media (max-width: 768px) {
font-family: var(--font-geist-mono); .hero {
padding-left: 0; padding: 50px 16px 40px 16px;
margin: 0; }
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
} }
.main li:not(:last-of-type) { .heroContent {
margin-bottom: 8px; max-width: 700px;
margin-bottom: 50px;
} }
.main code { .heroTitle {
font-family: inherit; font-size: 3.5rem;
background: var(--gray-alpha-100); font-weight: 800;
padding: 2px 4px; line-height: 1.15;
border-radius: 4px; color: var(--text-white);
font-weight: 600; margin-bottom: 20px;
letter-spacing: -0.02em;
} }
.ctas { @media (max-width: 768px) {
display: flex; .heroTitle {
gap: 16px; font-size: 2.2rem;
}
} }
.ctas a { .heroSubtitle {
appearance: none; font-size: 1.15rem;
border-radius: 128px; color: var(--text-secondary);
height: 48px; line-height: 1.6;
}
.searchContainer {
width: 100%;
max-width: 1000px;
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.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;
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 180px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
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) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center; text-align: center;
} }
.ctas { .routesGrid {
flex-direction: column; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 24px;
} }
.ctas a { .featuresGrid {
font-size: 14px; display: grid;
height: 40px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
padding: 0 16px; gap: 30px;
} }
a.secondary { .featureCard {
min-width: auto; 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);
} }
.footer { .featureCard:hover {
flex-wrap: wrap; background: rgba(255, 255, 255, 0.04);
align-items: center; transform: translateY(-4px);
justify-content: center; border-color: color-mix(in srgb, var(--primary) 30%, transparent);
}
} }
@media (prefers-color-scheme: dark) { .featureIcon {
.logo { font-size: 2.5rem;
filter: invert(); display: inline-block;
margin-bottom: 20px;
} }
.featureCard h4 {
font-size: 1.15rem;
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
View File
@@ -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 &amp; 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 &rarr; 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 &amp; 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 &rarr; 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 &amp; 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 &rarr; 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 &amp; 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>
); );
} }
+141
View File
@@ -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>
);
}
+89
View File
@@ -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;
}
}
+285
View File
@@ -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}>&rarr;</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 &amp; 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 &amp; 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>
);
}
+467
View File
@@ -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%;
}
}
+78
View File
@@ -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;
}
}
+54
View File
@@ -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} />;
}
+393
View File
@@ -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;
}
}
+410
View File
@@ -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} &rarr; {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>
);
}
+362
View File
@@ -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;
}
}
+480
View File
@@ -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} &rarr; {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>
);
}
+112
View File
@@ -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)
}
+79
View File
@@ -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>&copy; {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>
);
}
+313
View File
@@ -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);
}
+177
View File
@@ -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>
);
}
+70
View File
@@ -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;
}
+132
View File
@@ -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>
);
}
+383
View File
@@ -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;
}
}
+165
View File
@@ -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>
);
}
+45
View File
@@ -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;
}
+35
View File
@@ -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 };
+358
View File
@@ -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;
}
}
+74
View File
@@ -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;
}
}
+42
View File
@@ -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*'],
};