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 0000000..2ed6f35 Binary files /dev/null and b/public/uploads/logo-1781751329489.png differ 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*'], +};