From 5c0ab92401b8aec49776e87d04dd1cbc577db59a Mon Sep 17 00:00:00 2001 From: Rio Date: Thu, 18 Jun 2026 12:16:27 +0700 Subject: [PATCH] init --- .dockerignore | 9 + .env | 7 + .gitignore | 2 + Dockerfile | 42 + docker-compose.yml | 49 + docker-entrypoint.sh | 72 + next.config.mjs | 9 +- package-lock.json | 1188 ++++++++++++++++- package.json | 16 +- prisma.config.ts | 15 + prisma/schema.prisma | 79 ++ prisma/seed.js | 129 ++ public/uploads/logo-1781751329489.png | Bin 0 -> 45537 bytes src/app/admin/AdminLayout.module.css | 223 ++++ src/app/admin/bookings/bookings.module.css | 117 ++ src/app/admin/bookings/page.tsx | 257 ++++ src/app/admin/dashboard.module.css | 122 ++ src/app/admin/layout.tsx | 162 +++ src/app/admin/page.tsx | 192 +++ src/app/admin/routes/page.tsx | 289 ++++ src/app/admin/routes/routes.module.css | 157 +++ src/app/admin/schedules/page.tsx | 418 ++++++ src/app/admin/schedules/schedules.module.css | 166 +++ src/app/admin/settings/page.tsx | 505 +++++++ src/app/admin/settings/settings.module.css | 78 ++ src/app/admin/vehicles/page.tsx | 394 ++++++ src/app/admin/vehicles/vehicles.module.css | 242 ++++ src/app/api/admin/bookings/[id]/route.ts | 46 + src/app/api/admin/bookings/route.ts | 40 + src/app/api/admin/routes/[id]/route.ts | 49 + src/app/api/admin/routes/route.ts | 45 + src/app/api/admin/schedules/[id]/route.ts | 64 + src/app/api/admin/schedules/route.ts | 72 + src/app/api/admin/settings/route.ts | 94 ++ src/app/api/admin/stats/route.ts | 70 + src/app/api/admin/vehicles/route.ts | 46 + src/app/api/auth/login/route.ts | 62 + src/app/api/auth/logout/route.ts | 11 + src/app/api/auth/register/route.ts | 72 + src/app/api/auth/session/route.ts | 14 + .../bookings/[code]/check-pakasir/route.ts | 72 + src/app/api/bookings/[code]/pay/route.ts | 63 + src/app/api/bookings/[code]/qris/route.ts | 65 + src/app/api/bookings/[code]/route.ts | 46 + src/app/api/bookings/route.ts | 96 ++ src/app/api/routes/route.ts | 17 + src/app/api/schedules/route.ts | 78 ++ src/app/api/settings/route.ts | 24 + src/app/api/user/bookings/route.ts | 59 + src/app/api/webhook/pakasir/route.ts | 57 + src/app/booking/[scheduleId]/page.tsx | 61 + src/app/booking/checkout/[code]/page.tsx | 57 + src/app/dashboard/dashboard.module.css | 369 +++++ src/app/dashboard/page.tsx | 266 ++++ src/app/globals.css | 277 +++- src/app/layout.tsx | 119 +- src/app/login/login.module.css | 134 ++ src/app/login/page.tsx | 153 +++ src/app/page.module.css | 215 ++- src/app/page.tsx | 178 +-- src/app/register/page.tsx | 141 ++ src/app/register/register.module.css | 89 ++ src/app/search/page.tsx | 285 ++++ src/app/search/search.module.css | 467 +++++++ src/app/ticket-check/page.tsx | 78 ++ src/app/ticket-check/ticketcheck.module.css | 73 + src/app/ticket/[code]/page.tsx | 54 + src/components/BookingForm.module.css | 393 ++++++ src/components/BookingForm.tsx | 410 ++++++ src/components/CheckoutClient.module.css | 362 +++++ src/components/CheckoutClient.tsx | 480 +++++++ src/components/Footer.module.css | 112 ++ src/components/Footer.tsx | 79 ++ src/components/Header.module.css | 313 +++++ src/components/Header.tsx | 177 +++ src/components/SearchWidget.module.css | 70 + src/components/SearchWidget.tsx | 132 ++ src/components/TicketView.module.css | 383 ++++++ src/components/TicketView.tsx | 165 +++ src/lib/auth.ts | 45 + src/lib/db.ts | 35 + src/lib/email.ts | 358 +++++ src/lib/settings.ts | 74 + src/middleware.ts | 42 + 84 files changed, 12562 insertions(+), 285 deletions(-) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint.sh create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.js create mode 100644 public/uploads/logo-1781751329489.png create mode 100644 src/app/admin/AdminLayout.module.css create mode 100644 src/app/admin/bookings/bookings.module.css create mode 100644 src/app/admin/bookings/page.tsx create mode 100644 src/app/admin/dashboard.module.css create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/routes/page.tsx create mode 100644 src/app/admin/routes/routes.module.css create mode 100644 src/app/admin/schedules/page.tsx create mode 100644 src/app/admin/schedules/schedules.module.css create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/admin/settings/settings.module.css create mode 100644 src/app/admin/vehicles/page.tsx create mode 100644 src/app/admin/vehicles/vehicles.module.css create mode 100644 src/app/api/admin/bookings/[id]/route.ts create mode 100644 src/app/api/admin/bookings/route.ts create mode 100644 src/app/api/admin/routes/[id]/route.ts create mode 100644 src/app/api/admin/routes/route.ts create mode 100644 src/app/api/admin/schedules/[id]/route.ts create mode 100644 src/app/api/admin/schedules/route.ts create mode 100644 src/app/api/admin/settings/route.ts create mode 100644 src/app/api/admin/stats/route.ts create mode 100644 src/app/api/admin/vehicles/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/app/api/bookings/[code]/check-pakasir/route.ts create mode 100644 src/app/api/bookings/[code]/pay/route.ts create mode 100644 src/app/api/bookings/[code]/qris/route.ts create mode 100644 src/app/api/bookings/[code]/route.ts create mode 100644 src/app/api/bookings/route.ts create mode 100644 src/app/api/routes/route.ts create mode 100644 src/app/api/schedules/route.ts create mode 100644 src/app/api/settings/route.ts create mode 100644 src/app/api/user/bookings/route.ts create mode 100644 src/app/api/webhook/pakasir/route.ts create mode 100644 src/app/booking/[scheduleId]/page.tsx create mode 100644 src/app/booking/checkout/[code]/page.tsx create mode 100644 src/app/dashboard/dashboard.module.css create mode 100644 src/app/dashboard/page.tsx create mode 100644 src/app/login/login.module.css create mode 100644 src/app/login/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/app/register/register.module.css create mode 100644 src/app/search/page.tsx create mode 100644 src/app/search/search.module.css create mode 100644 src/app/ticket-check/page.tsx create mode 100644 src/app/ticket-check/ticketcheck.module.css create mode 100644 src/app/ticket/[code]/page.tsx create mode 100644 src/components/BookingForm.module.css create mode 100644 src/components/BookingForm.tsx create mode 100644 src/components/CheckoutClient.module.css create mode 100644 src/components/CheckoutClient.tsx create mode 100644 src/components/Footer.module.css create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.module.css create mode 100644 src/components/Header.tsx create mode 100644 src/components/SearchWidget.module.css create mode 100644 src/components/SearchWidget.tsx create mode 100644 src/components/TicketView.module.css create mode 100644 src/components/TicketView.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/email.ts create mode 100644 src/lib/settings.ts create mode 100644 src/middleware.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a9c8b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.git +.gitignore +*.md +.env +.env.* +docker-compose.yml +Dockerfile diff --git a/.env b/.env new file mode 100644 index 0000000..d60d692 --- /dev/null +++ b/.env @@ -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" \ No newline at end of file diff --git a/.gitignore b/.gitignore index fd3dbb5..0008ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c442c95 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..87ebe8c --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..660deb5 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 diff --git a/next.config.mjs b/next.config.mjs index 4678774..568f8f0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,11 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + } +}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index c5574d0..842929c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,19 +8,59 @@ "name": "travel-antarkota", "version": "0.1.0", "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-dom": "^18" + "react-dom": "^18", + "react-icons": "^5.6.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", "eslint-config-next": "14.2.35", + "prisma": "^7.8.0", "typescript": "^5" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -118,6 +158,19 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -203,6 +256,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -441,6 +501,428 @@ "node": ">=14" } }, + "node_modules/@prisma/adapter-mariadb": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-mariadb/-/adapter-mariadb-7.8.0.tgz", + "integrity": "sha512-mWsgcfbUjxB3qSzRlLs8E03vsKrqXzYK2zpx3e8u6wIgeHJM/sE46cuOGcYvHiZGmeQLCd3xL6YSSGM9QOLI6w==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "mariadb": "3.4.5" + } + }, + "node_modules/@prisma/adapter-mariadb/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@prisma/adapter-mariadb/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@prisma/adapter-mariadb/node_modules/mariadb": { + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.4.5.tgz", + "integrity": "sha512-gThTYkhIS5rRqkVr+Y0cIdzr+GRqJ9sA2Q34e0yzmyhMCwyApf3OKAC1jnF23aSlIOqJuyaUFUcj7O1qZslmmQ==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/node": "^24.0.13", + "denque": "^2.1.0", + "iconv-lite": "^0.6.3", + "lru-cache": "^10.4.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@prisma/adapter-mariadb/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@prisma/studio-core/node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -455,6 +937,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -482,6 +971,19 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -493,24 +995,33 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1405,6 +1916,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.12.1", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", @@ -1432,6 +1953,22 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", @@ -1454,6 +1991,35 @@ "node": ">=10.16.0" } }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -1551,6 +2117,35 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -1584,11 +2179,18 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1603,7 +2205,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -1692,6 +2294,16 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1728,6 +2340,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -1741,6 +2376,19 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1763,6 +2411,17 @@ "dev": true, "license": "MIT" }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1770,6 +2429,29 @@ "dev": true, "license": "MIT" }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -2411,11 +3093,41 @@ "node": ">=0.10.0" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -2432,6 +3144,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2532,7 +3261,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -2596,6 +3325,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -2631,6 +3370,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -2676,6 +3422,16 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", @@ -2791,6 +3547,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2798,6 +3561,13 @@ "dev": true, "license": "MIT" }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -2892,6 +3662,39 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3246,6 +4049,13 @@ "node": ">=8" } }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3402,7 +4212,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -3442,6 +4252,25 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3588,6 +4417,13 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3604,9 +4440,49 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mariadb": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/mariadb/-/mariadb-3.5.3.tgz", + "integrity": "sha512-i053Kc0MgdUv/hu9mCyq67TYfPXFj3/MV8I7ZW5wvJNixIyXC0VztMPUjIVj/449nQo+BsxFD4Fdk/sA/uqKPQ==", + "license": "LGPL-2.1-or-later", + "dependencies": { + "@types/geojson": "^7946.0.16", + "@types/node": ">=20", + "denque": "^2.1.0", + "iconv-lite": "^0.7.2", + "lru-cache": "^11.5.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/mariadb/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3657,6 +4533,40 @@ "dev": true, "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -3777,6 +4687,15 @@ "semver": "bin/semver.js" } }, + "node_modules/nodemailer": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", + "integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -3900,6 +4819,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4015,7 +4941,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4045,6 +4971,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4064,6 +5004,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -4102,6 +5054,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -4112,6 +5078,40 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4124,6 +5124,25 @@ "react-is": "^16.13.1" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4134,6 +5153,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4155,6 +5191,17 @@ ], "license": "MIT" }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -4180,6 +5227,15 @@ "react": "^18.3.1" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -4187,6 +5243,20 @@ "dev": true, "license": "MIT" }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4231,6 +5301,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -4275,6 +5365,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4404,6 +5504,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -4426,6 +5532,12 @@ "node": ">=10" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4479,7 +5591,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4492,7 +5604,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4578,7 +5690,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -4596,6 +5708,16 @@ "node": ">=0.10.0" } }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -4603,6 +5725,13 @@ "dev": true, "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -5072,7 +6201,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5105,7 +6234,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -5156,11 +6284,26 @@ "punycode": "^2.1.0" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5391,6 +6534,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } } } } diff --git a/package.json b/package.json index f2c60e2..9ccc6d3 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,26 @@ "lint": "next lint" }, "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-dom": "^18", - "next": "14.2.35" + "react-icons": "^5.6.0" }, "devDependencies": { - "typescript": "^5", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", + "@types/nodemailer": "^8.0.1", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", - "eslint-config-next": "14.2.35" + "eslint-config-next": "14.2.35", + "prisma": "^7.8.0", + "typescript": "^5" } } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..d1d83e2 --- /dev/null +++ b/prisma.config.ts @@ -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"], + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7eaf88b --- /dev/null +++ b/prisma/schema.prisma @@ -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 +} + diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 0000000..411af82 --- /dev/null +++ b/prisma/seed.js @@ -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(); + }); diff --git a/public/uploads/logo-1781751329489.png b/public/uploads/logo-1781751329489.png new file mode 100644 index 0000000000000000000000000000000000000000..2ed6f35e3cdae7805a0e2df0d497044132b05788 GIT binary patch literal 45537 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 FV1lt*b9Mj# literal 0 HcmV?d00001 diff --git a/src/app/admin/AdminLayout.module.css b/src/app/admin/AdminLayout.module.css new file mode 100644 index 0000000..a9a63ad --- /dev/null +++ b/src/app/admin/AdminLayout.module.css @@ -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; + } +} \ No newline at end of file diff --git a/src/app/admin/bookings/bookings.module.css b/src/app/admin/bookings/bookings.module.css new file mode 100644 index 0000000..af3cf8b --- /dev/null +++ b/src/app/admin/bookings/bookings.module.css @@ -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; +} \ No newline at end of file diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx new file mode 100644 index 0000000..47e24e4 --- /dev/null +++ b/src/app/admin/bookings/page.tsx @@ -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([]); + const [filteredBookings, setFilteredBookings] = useState([]); + 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 ( +
+
+

Memuat daftar tiket...

+
+ ); + } + + return ( +
+ {/* Search & Filter Top bar */} +
+
+ setSearchTerm(e.target.value)} + className="form-input" + /> +
+ +
+ +
+
+ + {error &&
{error}
} + + {/* Bookings Table */} +
+ {filteredBookings.length === 0 ? ( +

Tidak ada pemesanan tiket yang cocok.

+ ) : ( + + + + + + + + + + + + + + + {filteredBookings.map((b) => ( + + + + + + + + + + + ))} + +
KodePenumpang & KontakJadwal PerjalananKursiTotal TarifStatusMetodeAksi Admin
+ {b.bookingCode} + + {formatDate(b.createdAt)} + + +
+

{b.passengerName}

+

{b.passengerEmail}

+

WA: {b.passengerPhone}

+
+
+
+

{b.schedule.route.departureCity} → {b.schedule.route.arrivalCity}

+

{b.schedule.vehicleType}

+

+ {new Date(b.schedule.departureTime).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })} +

+
+
+ + {b.seats.map(s => s.seatNumber).join(', ')} + + + {formatCurrency(b.totalPrice)} + + + {b.status === 'PAID' ? 'Lunas' : b.status === 'CANCELLED' ? 'Batal' : 'Pending'} + + + {b.paymentMethod || '-'} + +
+ {b.status === 'PENDING' && ( + <> + + + + )} + {b.status === 'PAID' && ( + + )} + {b.status === 'CANCELLED' && ( + Tidak ada aksi + )} +
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/dashboard.module.css b/src/app/admin/dashboard.module.css new file mode 100644 index 0000000..e406baa --- /dev/null +++ b/src/app/admin/dashboard.module.css @@ -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; +} \ No newline at end of file diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..8e13f49 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [brandSettings, setBrandSettings] = useState(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 ( +
+
+

Memuat Sesi Admin...

+
+ ); + } + + return ( +
+ + +
+
+

+ {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'} +

+
+ {new Date().toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} +
+
+
+ {children} +
+
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..3ba933b --- /dev/null +++ b/src/app/admin/page.tsx @@ -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(null); + const [recentBookings, setRecentBookings] = useState([]); + 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 ( +
+
+

Mengambil statistik data...

+
+ ); + } + + if (error) { + return
{error}
; + } + + return ( +
+ {/* Stats Cards Row */} + {stats && ( +
+
+
+
+ Total Pendapatan +

{formatCurrency(stats.totalRevenue)}

+
+
+ +
+
+
+ Tiket Terjual +

{stats.paidBookingsCount} Booking

+
+
+ +
+
+
+ Menunggu Bayar +

{stats.pendingBookingsCount} Booking

+
+
+ +
+
+
+ Jumlah Rute +

{stats.routesCount} Aktif

+
+
+
+ )} + + {/* Recent Transactions Table Section */} +
+
+

Pemesanan Tiket Terbaru

+ + Lihat Semua Tiket + +
+ + {recentBookings.length === 0 ? ( +

Belum ada pemesanan tiket masuk.

+ ) : ( +
+ + + + + + + + + + + + + + {recentBookings.map((b) => ( + + + + + + + + + + ))} + +
KodePenumpangRute KeberangkatanKursiTotal BiayaStatusTanggal
+ {b.bookingCode} + +
+

{b.passengerName}

+

{b.passengerPhone}

+
+
+
+

{b.schedule.route.departureCity} → {b.schedule.route.arrivalCity}

+

{b.schedule.vehicleType}

+
+
+ {b.seats.map(s => s.seatNumber).join(', ')} + + {formatCurrency(b.totalPrice)} + + + {b.status === 'PAID' ? 'Lunas' : b.status === 'CANCELLED' ? 'Batal' : 'Pending'} + + + {formatDate(b.createdAt)} +
+
+ )} +
+
+ ); +} diff --git a/src/app/admin/routes/page.tsx b/src/app/admin/routes/page.tsx new file mode 100644 index 0000000..2555c04 --- /dev/null +++ b/src/app/admin/routes/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Form states + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(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 ( +
+
+

Memuat data rute...

+
+ ); + } + + return ( +
+
+

Daftar rute perjalanan shuttle dan bis yang aktif beroperasi.

+ +
+ + {error &&
{error}
} + + {/* Routes list Table */} +
+ {routes.length === 0 ? ( +

Belum ada rute perjalanan. Silakan tambah rute baru.

+ ) : ( + + + + + + + + + + + + + {routes.map((route) => ( + + + + + + + + + ))} + +
No. RuteKota AsalKota TujuanEstimasi DurasiHarga DasarAksi
+ #{route.id} + + {route.departureCity} + + {route.arrivalCity} + + {Math.floor(route.durationMinutes / 60)} Jam {route.durationMinutes % 60} Menit + + {formatCurrency(route.basePrice)} + +
+ + +
+
+ )} +
+ + {/* Slide-out Overlay Modal Form */} + {showForm && ( +
+
+
+

{editingId ? 'Edit Rute Perjalanan' : 'Tambah Rute Baru'}

+ +
+ +
+ {formError &&
{formError}
} + +
+ + setDepartureCity(e.target.value)} + className="form-input" + /> +
+ +
+ + setArrivalCity(e.target.value)} + className="form-input" + /> +
+ +
+ + setDurationMinutes(e.target.value)} + className="form-input" + /> +
+ +
+ + setBasePrice(e.target.value)} + className="form-input" + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/admin/routes/routes.module.css b/src/app/admin/routes/routes.module.css new file mode 100644 index 0000000..dbae9e4 --- /dev/null +++ b/src/app/admin/routes/routes.module.css @@ -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; +} \ No newline at end of file diff --git a/src/app/admin/schedules/page.tsx b/src/app/admin/schedules/page.tsx new file mode 100644 index 0000000..ef1fab8 --- /dev/null +++ b/src/app/admin/schedules/page.tsx @@ -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([]); + const [routes, setRoutes] = useState([]); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Form states + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(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([]); + + 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 ( +
+
+

Memuat data jadwal...

+
+ ); + } + + return ( +
+
+

Daftar jadwal harian keberangkatan armada shuttle dan bus.

+ +
+ + {error &&
{error}
} + + {routes.length === 0 && ( +
+ Belum ada rute perjalanan yang aktif. Silakan tambahkan rute perjalanan terlebih dahulu sebelum membuat jadwal. +
+ )} + + {/* Schedules list Table */} +
+ {schedules.length === 0 ? ( +

Belum ada jadwal keberangkatan.

+ ) : ( + + + + + + + + + + + + + + {schedules.map((s) => ( + + + + + + + + + + ))} + +
RuteKeberangkatanKedatanganArmadaKapasitasTarif TiketAksi
+ {s.route.departureCity} → {s.route.arrivalCity} + + {formatDateDisplay(s.departureTime)} + + {formatDateDisplay(s.arrivalTime)} + + {s.vehicleType} + + {s.capacity} Kursi + + {formatCurrency(s.price)} + +
+ + +
+
+ )} +
+ + {/* Overlay Modal Form */} + {showForm && ( +
+
+
+

{editingId ? 'Edit Jadwal Perjalanan' : 'Tambah Jadwal Baru'}

+ +
+ +
+ {formError &&
{formError}
} + +
+ + +
+ +
+ + +
+ +
+ + setDepartureTime(e.target.value)} + className="form-input" + /> +
+ +
+ + setArrivalTime(e.target.value)} + className="form-input" + /> +
+ +
+ + +
+ +
+ + setPrice(e.target.value)} + className="form-input" + /> +
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/admin/schedules/schedules.module.css b/src/app/admin/schedules/schedules.module.css new file mode 100644 index 0000000..a3f38e2 --- /dev/null +++ b/src/app/admin/schedules/schedules.module.css @@ -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; +} \ No newline at end of file diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx new file mode 100644 index 0000000..88da760 --- /dev/null +++ b/src/app/admin/settings/page.tsx @@ -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({ + 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(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) => { + 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 ( +
+
+

Memuat pengaturan...

+
+ ); + } + + return ( +
+ {successMsg &&
{successMsg}
} + {errorMsg &&
{errorMsg}
} + +
+
+ {/* Card 1: Branding */} +
+

🎨 Identitas Brand

+ +
+ + +
+ +
+ + +
+
+ {logoFile ? ( + // eslint-disable-next-line @next/next/no-img-element + Logo Preview + ) : settings.logoImageUrl && !deleteLogoImage ? ( + // eslint-disable-next-line @next/next/no-img-element + Logo + ) : ( + + )} +
+ +
+ { + if (e.target.files && e.target.files[0]) { + setLogoFile(e.target.files[0]); + setDeleteLogoImage(false); + } + }} + style={{ display: 'none' }} + /> + + + {((settings.logoImageUrl && !deleteLogoImage) || logoFile) && ( + + )} +
+
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+
+ + + {/* Card 2: Customer Service */} +
+

Kontak Customer Service

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* Card 3: Pakasir Integration */} +
+

Integrasi QRIS Pakasir

+

+ Masukkan slug proyek dan API Key Anda dari dashboard Pakasir. +

+ +
+ + +
+ +
+ + +
+ +
+ 🔗 URL Webhook Callback Pakasir: +
+ (e.target as HTMLInputElement).select()} + /> + +
+

+ Daftarkan URL di atas pada dashboard Pakasir untuk mendeteksi pembayaran QRIS otomatis. +

+
+
+ + {/* Card 4: SMTP Configuration */} +
+

Pengaturan Email (SMTP)

+

+ Konfigurasi kredensial SMTP untuk mengirimkan e-ticket otomatis setelah pembayaran berhasil. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/app/admin/settings/settings.module.css b/src/app/admin/settings/settings.module.css new file mode 100644 index 0000000..562f6d8 --- /dev/null +++ b/src/app/admin/settings/settings.module.css @@ -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); + } +} \ No newline at end of file diff --git a/src/app/admin/vehicles/page.tsx b/src/app/admin/vehicles/page.tsx new file mode 100644 index 0000000..acdced0 --- /dev/null +++ b/src/app/admin/vehicles/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [errorMsg, setErrorMsg] = useState(''); + const [successMsg, setSuccessMsg] = useState(''); + + // Editor states + const [isEditing, setIsEditing] = useState(false); + const [editIndex, setEditIndex] = useState(null); // null means creating new + const [editName, setEditName] = useState(''); + const [editRows, setEditRows] = useState(5); + const [editCols, setEditCols] = useState(4); + const [editSeats, setEditSeats] = useState([]); + + 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 ( +
+
+

Memuat tata letak armada...

+
+ ); + } + + return ( +
+ {successMsg &&
{successMsg}
} + {errorMsg &&
{errorMsg}
} + + {!isEditing ? ( + <> +
+

+ Daftar tata letak armada dan konfigurasi kapasitas kursi untuk visual pemesanan pelanggan. +

+ +
+ +
+ {layouts.map((layout, idx) => ( +
+
+
+

{layout.name}

+ + Kapasitas: {layout.capacity} Kursi + +
+ + {layout.name.toLowerCase().includes('hiace') ? : } + +
+ +
+ Grid Ukuran: {layout.rows} Baris × {layout.cols} Kolom + Definisi Kursi: Aktif +
+ +
+ + +
+
+ ))} +
+ + ) : ( + /* GRID EDITOR VIEW */ +
+ {/* Configurations Sidebar */} +
+

Konfigurasi Tata Letak

+ +
+ + setEditName(e.target.value)} + className="form-input" + placeholder="Contoh: Toyota HiAce Premio (11 Seater)" + /> +
+ +
+
+ + setEditRows(Math.max(1, Number(e.target.value)))} + className="form-input" + /> +
+ +
+ + setEditCols(Math.max(1, Number(e.target.value)))} + className="form-input" + /> +
+
+ +
+

💡 Petunjuk Editor Seating

+
    +
  • Klik pada grid kosong (garis putus-putus) untuk menambahkan kursi.
  • +
  • Gunakan kolom teks di dalam kursi untuk menamai nomor kursi (misal: A1, 12, VIP).
  • +
  • Klik ikon x merah di pojok kanan atas kursi untuk menghapusnya.
  • +
  • Kapasitas armada dihitung otomatis berdasarkan jumlah kursi aktif.
  • +
+
+ +
+ + Kapasitas Dihitung: {editSeats.length} Kursi + +
+ +
+ + +
+
+ + {/* Interactive Seat Editor Canvas */} +
+

+ Visual Seating Grid Map +

+ +
+ {/* Dashboard Layout Header mimicking vehicle direction */} +
+
Roda Kemudi (Depan)
+
Sopir
+
+ + {/* Grid Seat Layout */} +
+ {Array.from({ length: editRows }).map((_, rIdx) => + Array.from({ length: editCols }).map((_, cIdx) => { + const seat = isSeatAt(rIdx, cIdx); + if (seat) { + return ( +
+ handleLabelChange(rIdx, cIdx, e.target.value)} + className={styles.seatInput} + title="Klik untuk ubah label kursi" + /> + +
+ ); + } else { + return ( + + ); + } + }) + )} +
+
+
+
+ )} +
+ ); +} diff --git a/src/app/admin/vehicles/vehicles.module.css b/src/app/admin/vehicles/vehicles.module.css new file mode 100644 index 0000000..4f9fc3e --- /dev/null +++ b/src/app/admin/vehicles/vehicles.module.css @@ -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); + } +} \ No newline at end of file diff --git a/src/app/api/admin/bookings/[id]/route.ts b/src/app/api/admin/bookings/[id]/route.ts new file mode 100644 index 0000000..c5bad0f --- /dev/null +++ b/src/app/api/admin/bookings/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/bookings/route.ts b/src/app/api/admin/bookings/route.ts new file mode 100644 index 0000000..7ab9ee3 --- /dev/null +++ b/src/app/api/admin/bookings/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/routes/[id]/route.ts b/src/app/api/admin/routes/[id]/route.ts new file mode 100644 index 0000000..a53131a --- /dev/null +++ b/src/app/api/admin/routes/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/routes/route.ts b/src/app/api/admin/routes/route.ts new file mode 100644 index 0000000..994b2da --- /dev/null +++ b/src/app/api/admin/routes/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/schedules/[id]/route.ts b/src/app/api/admin/schedules/[id]/route.ts new file mode 100644 index 0000000..1eb95bb --- /dev/null +++ b/src/app/api/admin/schedules/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/schedules/route.ts b/src/app/api/admin/schedules/route.ts new file mode 100644 index 0000000..106e5b7 --- /dev/null +++ b/src/app/api/admin/schedules/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts new file mode 100644 index 0000000..e79972a --- /dev/null +++ b/src/app/api/admin/settings/route.ts @@ -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 }); + } +} + diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..49442c0 --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/vehicles/route.ts b/src/app/api/admin/vehicles/route.ts new file mode 100644 index 0000000..4e21c17 --- /dev/null +++ b/src/app/api/admin/vehicles/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..5cf571b --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..8c9ad05 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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; +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..bc92a99 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..3721631 --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -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 }); + } +} diff --git a/src/app/api/bookings/[code]/check-pakasir/route.ts b/src/app/api/bookings/[code]/check-pakasir/route.ts new file mode 100644 index 0000000..d6b1fa0 --- /dev/null +++ b/src/app/api/bookings/[code]/check-pakasir/route.ts @@ -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 }); + } +} diff --git a/src/app/api/bookings/[code]/pay/route.ts b/src/app/api/bookings/[code]/pay/route.ts new file mode 100644 index 0000000..007d88e --- /dev/null +++ b/src/app/api/bookings/[code]/pay/route.ts @@ -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 }); + } +} + diff --git a/src/app/api/bookings/[code]/qris/route.ts b/src/app/api/bookings/[code]/qris/route.ts new file mode 100644 index 0000000..8786fd3 --- /dev/null +++ b/src/app/api/bookings/[code]/qris/route.ts @@ -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 }); + } +} diff --git a/src/app/api/bookings/[code]/route.ts b/src/app/api/bookings/[code]/route.ts new file mode 100644 index 0000000..a3986df --- /dev/null +++ b/src/app/api/bookings/[code]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/bookings/route.ts b/src/app/api/bookings/route.ts new file mode 100644 index 0000000..f216bb4 --- /dev/null +++ b/src/app/api/bookings/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/routes/route.ts b/src/app/api/routes/route.ts new file mode 100644 index 0000000..4efc3fd --- /dev/null +++ b/src/app/api/routes/route.ts @@ -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 }); + } +} diff --git a/src/app/api/schedules/route.ts b/src/app/api/schedules/route.ts new file mode 100644 index 0000000..ec9bf36 --- /dev/null +++ b/src/app/api/schedules/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts new file mode 100644 index 0000000..257f04a --- /dev/null +++ b/src/app/api/settings/route.ts @@ -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 }); + } +} diff --git a/src/app/api/user/bookings/route.ts b/src/app/api/user/bookings/route.ts new file mode 100644 index 0000000..ba53628 --- /dev/null +++ b/src/app/api/user/bookings/route.ts @@ -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 }); + } +} diff --git a/src/app/api/webhook/pakasir/route.ts b/src/app/api/webhook/pakasir/route.ts new file mode 100644 index 0000000..8354ee9 --- /dev/null +++ b/src/app/api/webhook/pakasir/route.ts @@ -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 }); + } +} diff --git a/src/app/booking/[scheduleId]/page.tsx b/src/app/booking/[scheduleId]/page.tsx new file mode 100644 index 0000000..38fda73 --- /dev/null +++ b/src/app/booking/[scheduleId]/page.tsx @@ -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 ( + + ); +} diff --git a/src/app/booking/checkout/[code]/page.tsx b/src/app/booking/checkout/[code]/page.tsx new file mode 100644 index 0000000..eb1336c --- /dev/null +++ b/src/app/booking/checkout/[code]/page.tsx @@ -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 ; +} + diff --git a/src/app/dashboard/dashboard.module.css b/src/app/dashboard/dashboard.module.css new file mode 100644 index 0000000..ec6d795 --- /dev/null +++ b/src/app/dashboard/dashboard.module.css @@ -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; + } +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..4804da3 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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(null); + const [bookings, setBookings] = useState([]); + const [loading, setLoading] = useState(true); + const [copiedCode, setCopiedCode] = useState(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 ( +
+
+

Memuat dashboard Anda...

+
+ ); + } + + 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 ( +
+ {/* Header */} +
+
+

Halo, {user.name}!

+

Kelola pesanan dan tiket perjalanan Anda di sini.

+
+
+ + {/* Stats Summary */} +
+
+ Total Pemesanan + {totalBookings} +
+
+ Tiket Aktif (Lunas) + {activeTickets} +
+
+ Menunggu Pembayaran + {pendingPayments} +
+
+ + {/* Bookings Section */} +
+

Daftar Riwayat Perjalanan

+ + {bookings.length === 0 ? ( +
+ +

Belum Ada Pesanan

+

Anda belum melakukan pemesanan tiket perjalanan apa pun saat ini.

+ + Cari & Pesan Tiket Sekarang + +
+ ) : ( +
+ {bookings.map((booking) => ( +
+ {/* Main Details */} +
+
+
+ {booking.schedule.departureCity} + {formatTime(booking.schedule.departureTime)} +
+ +
+ {booking.schedule.arrivalCity} + {formatTime(booking.schedule.arrivalTime)} +
+
+ +
+
+ Tanggal Perjalanan + {formatDate(booking.schedule.departureTime)} +
+
+ Jenis Armada + {booking.schedule.vehicleType} +
+
+ Kursi Terpilih +
+ {booking.seats.map((seat) => ( + + {seat} + + ))} +
+
+
+ Nama Penumpang + {booking.passengerName} +
+
+
+ + {/* Sidebar Details & Actions */} +
+
+
+ Kode Booking +
+ {booking.bookingCode} + +
+
+ +
+ {formatCurrency(booking.totalPrice)} + + {booking.status === 'PAID' ? 'LUNAS' : booking.status === 'PENDING' ? 'PENDING' : 'BATAL'} + +
+
+ + {/* Contextual Action Button */} +
+ {booking.status === 'PENDING' && ( + + Selesaikan Pembayaran + + )} + {booking.status === 'PAID' && ( + + Lihat Tiket Digital + + )} +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index e3734be..6c87cd2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,33 +1,66 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); + :root { - --background: #ffffff; - --foreground: #171717; -} + --font-inter: 'Inter', sans-serif; -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} + /* Color Palette - Premium Dynamic Dark Theme */ + --bg-dark: color-mix(in srgb, var(--primary) 4%, #04050a); + --bg-deep: color-mix(in srgb, var(--primary) 7%, #020306); + --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, -body { - max-width: 100vw; - overflow-x: hidden; -} + --text-white: #ffffff; + --text-primary: #f3f4f6; + --text-secondary: color-mix(in srgb, var(--primary) 15%, #9ca3af); + --text-muted: color-mix(in srgb, var(--primary) 10%, #6b7280); -body { - color: var(--foreground); - background: var(--background); - font-family: Arial, Helvetica, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + --primary: #6366f1; + /* Indigo-500 */ + --primary-hover: color-mix(in srgb, var(--primary) 85%, black); + --primary-glow: color-mix(in srgb, var(--primary) 25%, transparent); + --primary-text: var(--primary); + --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; padding: 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 { @@ -35,8 +68,206 @@ a { text-decoration: none; } -@media (prefers-color-scheme: dark) { - html { - color-scheme: dark; +/* Glassmorphism Containers */ +.glass-panel { + 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; + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dca06ae..f65aca0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,110 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +import type { Metadata } from 'next'; +import './globals.css'; +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import { getSettings } from '@/lib/settings'; -const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", - weight: "100 900", -}); -const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", - weight: "100 900", -}); +export async function generateMetadata(): Promise { + const settings = await getSettings(); + return { + title: `${settings.brandName} - Pemesanan Tiket Bis & Shuttle`, + description: `Pesan tiket bis dan shuttle antarkota dengan mudah, cepat, dan aman di ${settings.brandName}.`, + }; +} -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +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 +} -export default function RootLayout({ +function isVeryLightColor(hexColor: string): boolean { + const hex = hexColor.replace('#', ''); + if (hex.length !== 6) return false; + + const r = parseInt(hex.substring(0, 2), 16); + const g = parseInt(hex.substring(2, 4), 16); + const b = parseInt(hex.substring(4, 6), 16); + + const brightness = Math.sqrt( + 0.299 * (r * r) + + 0.587 * (g * g) + + 0.114 * (b * b) + ); + + return brightness > 190; +} + +export default async function RootLayout({ children, }: Readonly<{ 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 ( - - - {children} + + + + + + + + + `; + + // 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; + } +} diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..7a5be43 --- /dev/null +++ b/src/lib/settings.ts @@ -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; + } +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..bc54aef --- /dev/null +++ b/src/middleware.ts @@ -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*'], +};