From ad4b10f27e7e622694c1e798a25d4a4f212fbdfc Mon Sep 17 00:00:00 2001 From: Indra Gunanda Date: Tue, 2 Dec 2025 22:14:01 +0700 Subject: [PATCH 01/37] feat: Configure npm with `.npmrc`, update Dockerfile to include it and use `KEY=VALUE` syntax for environment variables, and add a type annotation in the scan API. --- .npmrc | 2 ++ Dockerfile | 10 +++++----- src/app/api/scan/route.ts | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7e02861 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +legacy-peer-deps=true +loglevel=error diff --git a/Dockerfile b/Dockerfile index 2048ccb..c3f6f55 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN apk add --no-cache libc6-compat WORKDIR /app # Install dependencies based on the preferred package manager -COPY package.json package-lock.json* ./ +COPY package.json package-lock.json* .npmrc ./ RUN npm ci # Rebuild the source code only when needed @@ -19,7 +19,7 @@ COPY . . # Next.js collects completely anonymous telemetry data about general usage. # Learn more here: https://nextjs.org/telemetry # Uncomment the following line in case you want to disable telemetry during the build. -ENV NEXT_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 RUN npm run build @@ -27,9 +27,9 @@ RUN npm run build FROM base AS runner WORKDIR /app -ENV NODE_ENV production +ENV NODE_ENV=production # Uncomment the following line in case you want to disable telemetry during runtime. -ENV NEXT_TELEMETRY_DISABLED 1 +ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs @@ -49,7 +49,7 @@ USER nextjs EXPOSE 3000 -ENV PORT 3000 +ENV PORT=3000 # server.js is created by next build from the standalone output # https://nextjs.org/docs/pages/api-reference/next-config-js/output diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts index 9c7a6f4..a25ed55 100644 --- a/src/app/api/scan/route.ts +++ b/src/app/api/scan/route.ts @@ -263,7 +263,7 @@ export async function POST(request: NextRequest) { include: { brand: true }, }); - const productInfo = products.map((p) => { + const productInfo = products.map((p: (typeof products)[number]) => { const metadata = p.metadata as ProductMetadata; return { code: p.code, From 3c23eb6a3c4ed1a817981c8da302a3ec3ad126ee Mon Sep 17 00:00:00 2001 From: Indra Gunanda Date: Tue, 2 Dec 2025 22:56:04 +0700 Subject: [PATCH 02/37] refactor: improve type safety in scan and verify API routes and ensure Prisma client generation during build. --- Dockerfile | 3 +++ src/app/api/scan/route.ts | 50 +++++++++++++++++++++---------------- src/app/api/verify/route.ts | 2 +- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/Dockerfile b/Dockerfile index c3f6f55..69b7563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,9 @@ COPY . . # Uncomment the following line in case you want to disable telemetry during the build. ENV NEXT_TELEMETRY_DISABLED=1 +# Generate Prisma client before building +RUN npx prisma generate + RUN npm run build # Production image, copy all the files and run next diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts index a25ed55..91022a2 100644 --- a/src/app/api/scan/route.ts +++ b/src/app/api/scan/route.ts @@ -275,15 +275,18 @@ export async function POST(request: NextRequest) { }); // Calculate scan statistics + type TagScan = (typeof tag.scans)[number]; const totalScans = tag.scans.length; const scansFromFingerprint = tag.scans.filter( - (s) => s.fingerprint_id === fingerprintId + (s: TagScan) => s.fingerprint_id === fingerprintId ); const previousScansFromFingerprint = scansFromFingerprint.length; const isNewFingerprint = previousScansFromFingerprint === 0; // Count unique fingerprints that have scanned this tag - const uniqueFingerprints = new Set(tag.scans.map((s) => s.fingerprint_id)); + const uniqueFingerprints = new Set( + tag.scans.map((s: TagScan) => s.fingerprint_id) + ); const uniqueScannerCount = uniqueFingerprints.size; // Determine what question to ask (based on unique scanners, not total scans) @@ -358,23 +361,28 @@ export async function POST(request: NextRequest) { }); // Build history for display (only if more than 3 unique scanners) - let history: ScanResponse['history'] = undefined; - if (uniqueScannerCount >= 3 || question?.type === 'no_question') { - history = tag.scans.map((s) => ({ - scanNumber: s.scan_number, - createdAt: s.created_at.toISOString(), - isFirstHand: - s.is_first_hand === 1 ? true : s.is_first_hand === 0 ? false : null, - sourceInfo: s.source_info, - })); - // Add current scan to history - history.unshift({ - scanNumber: totalScans + 1, - createdAt: newScan.created_at.toISOString(), - isFirstHand: null, - sourceInfo: null, - }); - } + const history: ScanResponse['history'] = + uniqueScannerCount >= 3 || question?.type === 'no_question' + ? [ + { + scanNumber: totalScans + 1, + createdAt: newScan.created_at.toISOString(), + isFirstHand: null, + sourceInfo: null, + }, + ...tag.scans.map((s: TagScan) => ({ + scanNumber: s.scan_number, + createdAt: s.created_at.toISOString(), + isFirstHand: + s.is_first_hand === 1 + ? true + : s.is_first_hand === 0 + ? false + : null, + sourceInfo: s.source_info, + })), + ] + : undefined; // Perform fraud detection if location is available and tag has distribution info let fraudAnalysis: ScanResponse['fraudAnalysis'] = undefined; @@ -385,9 +393,9 @@ export async function POST(request: NextRequest) { ) { // Get recent scan locations for context const recentLocations = tag.scans - .filter((s) => s.location_name) + .filter((s: TagScan) => s.location_name) .slice(0, 5) - .map((s) => s.location_name as string); + .map((s: TagScan) => s.location_name as string); try { // Use AI-powered fraud detection diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts index 60e6cb7..5bc2732 100644 --- a/src/app/api/verify/route.ts +++ b/src/app/api/verify/route.ts @@ -216,7 +216,7 @@ export async function GET(request: NextRequest) { include: { brand: true }, }); - const productInfo = products.map((p) => { + const productInfo = products.map((p: (typeof products)[number]) => { const metadata = p.metadata as ProductMetadata; return { code: p.code, From 2eefec33f3f837d8afbf2a6294f70f60ee62eb40 Mon Sep 17 00:00:00 2001 From: Indra Gunanda Date: Tue, 2 Dec 2025 23:08:44 +0700 Subject: [PATCH 03/37] feat: add complete database seeding script and command for realistic data. --- package.json | 3 +- scripts/seed-complete.ts | 1161 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 1163 insertions(+), 1 deletion(-) create mode 100644 scripts/seed-complete.ts diff --git a/package.json b/package.json index 1458238..72049d6 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "db:studio": "prisma studio", "db:create-admin": "tsx scripts/create-admin.ts", "db:seed": "tsx scripts/seed-data.ts", - "db:seed-fraud": "tsx scripts/seed-fraud-scans.ts" + "db:seed-fraud": "tsx scripts/seed-fraud-scans.ts", + "db:seed-complete": "tsx scripts/seed-complete.ts" }, "config": { "commitizen": { diff --git a/scripts/seed-complete.ts b/scripts/seed-complete.ts new file mode 100644 index 0000000..31643bd --- /dev/null +++ b/scripts/seed-complete.ts @@ -0,0 +1,1161 @@ +/** + * Complete seed script with: + * - Realistic brand accounts with users + * - Product data with R2 image uploads + * - Tags with proper distribution metadata + * - QR code generation and R2 upload + * - Various scan patterns including suspicious ones + * + * Usage: + * npx tsx scripts/seed-complete.ts [--upload-r2] [--clean] + * + * Options: + * --upload-r2 Upload QR codes and metadata to R2 (requires R2 env vars) + * --clean Clear existing data before seeding + */ + +import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcryptjs'; +import QRCode from 'qrcode'; + +const prisma = new PrismaClient(); + +// Check command line args +const UPLOAD_TO_R2 = process.argv.includes('--upload-r2'); +const CLEAN_DATA = process.argv.includes('--clean'); + +// R2 upload function (conditional import) +let uploadFile: + | (( + key: string, + body: Buffer, + contentType: string + ) => Promise<{ url: string }>) + | null = null; +let getFileUrl: ((key: string) => string) | null = null; + +async function initR2() { + if (UPLOAD_TO_R2) { + try { + const r2Module = await import('../src/lib/r2'); + uploadFile = r2Module.uploadFile; + getFileUrl = r2Module.getFileUrl; + console.log('R2 module loaded successfully'); + } catch (error) { + console.error('Failed to load R2 module. Make sure R2 env vars are set.'); + console.error(error); + process.exit(1); + } + } +} + +// ============================================================================ +// REALISTIC BRAND DATA +// ============================================================================ + +type BrandData = { + name: string; + description: string; + category: string; + user: { + email: string; + password: string; + name: string; + }; + products: ProductData[]; +}; + +type ProductData = { + name: string; + description: string; + price: number; + category: string; + sku: string; + specs: Record; + weight: string; + color?: string; + size?: string; + material?: string; +}; + +const BRANDS: BrandData[] = [ + { + name: 'Batik Keris', + description: + 'Brand batik premium Indonesia sejak 1970. Menyediakan batik tulis dan cap berkualitas tinggi dengan motif tradisional Jawa.', + category: 'Fashion', + user: { + email: 'admin@batikkeris.id', + password: 'batik2024', + name: 'Siti Rahayu', + }, + products: [ + { + name: 'Kemeja Batik Parang Rusak', + description: + 'Kemeja batik tulis motif Parang Rusak, simbol kekuatan dan keberanian. Dibuat dengan teknik tulis tradisional.', + price: 850000, + category: 'Shirt', + sku: 'BK-KMPR-001', + specs: { technique: 'Batik Tulis', origin: 'Solo' }, + weight: '250g', + material: 'Katun Primisima', + size: 'L', + color: 'Sogan Brown', + }, + { + name: 'Dress Batik Mega Mendung', + description: + 'Dress batik cap motif Mega Mendung khas Cirebon. Cocok untuk acara formal maupun casual.', + price: 650000, + category: 'Dress', + sku: 'BK-DRMM-002', + specs: { technique: 'Batik Cap', origin: 'Cirebon' }, + weight: '300g', + material: 'Katun Doby', + size: 'M', + color: 'Navy Blue', + }, + { + name: 'Kain Batik Kawung', + description: + 'Kain batik tulis motif Kawung klasik. Motif yang melambangkan kesucian dan keadilan.', + price: 1200000, + category: 'Fabric', + sku: 'BK-KBKW-003', + specs: { + technique: 'Batik Tulis', + origin: 'Yogyakarta', + length: '2.5m', + }, + weight: '400g', + material: 'Sutra ATBM', + }, + ], + }, + { + name: 'Kopi Nusantara', + description: + 'Kopi specialty Indonesia dari berbagai daerah. Dari petani langsung ke cangkir Anda.', + category: 'Food & Beverage', + user: { + email: 'admin@kopinusantara.co.id', + password: 'kopi2024', + name: 'Ahmad Fauzi', + }, + products: [ + { + name: 'Kopi Gayo Arabica Premium', + description: + 'Biji kopi Arabica pilihan dari dataran tinggi Gayo, Aceh. Rasa wine, fruity dengan aroma earthy.', + price: 185000, + category: 'Coffee Beans', + sku: 'KN-GAP-001', + specs: { + origin: 'Gayo, Aceh', + altitude: '1400-1700m', + process: 'Wet Hulled', + roast: 'Medium', + }, + weight: '250g', + }, + { + name: 'Kopi Toraja Sapan', + description: + 'Single origin dari Toraja Utara. Notes cokelat, rempah dengan body yang full.', + price: 165000, + category: 'Coffee Beans', + sku: 'KN-TRS-002', + specs: { + origin: 'Toraja, Sulawesi', + altitude: '1500-1800m', + process: 'Natural', + roast: 'Medium-Dark', + }, + weight: '250g', + }, + { + name: 'Kopi Kintamani Bali', + description: + 'Kopi Arabica dari lereng Gunung Batur. Citrus, lemon dengan acidity yang bright.', + price: 155000, + category: 'Coffee Beans', + sku: 'KN-KTB-003', + specs: { + origin: 'Kintamani, Bali', + altitude: '1200-1600m', + process: 'Washed', + roast: 'Light-Medium', + }, + weight: '250g', + }, + { + name: 'Drip Bag Coffee Mix Pack', + description: + 'Paket 10 drip bag dengan 5 varian kopi Nusantara. Praktis untuk travel.', + price: 95000, + category: 'Drip Bag', + sku: 'KN-DBM-004', + specs: { contains: '10 sachets', varieties: '5 origins' }, + weight: '150g', + }, + ], + }, + { + name: 'Sepatu Compass', + description: + 'Brand sepatu lokal Indonesia dengan kualitas internasional. Sneakers dengan desain timeless.', + category: 'Footwear', + user: { + email: 'brand@sepatucompass.id', + password: 'compass2024', + name: 'Budi Santoso', + }, + products: [ + { + name: 'Compass Gazelle Low Black', + description: + 'Sneakers klasik dengan desain minimalis. Upper canvas premium dengan sole vulcanized.', + price: 398000, + category: 'Sneakers', + sku: 'CP-GZL-BK01', + specs: { + sole: 'Rubber Vulcanized', + upper: 'Canvas Premium', + closure: 'Lace-up', + }, + weight: '450g', + size: '42', + color: 'Black/White', + }, + { + name: 'Compass Retrograde High', + description: + 'High-top sneakers dengan desain retro 80s. Cocok untuk daily wear.', + price: 448000, + category: 'Sneakers', + sku: 'CP-RTG-HI02', + specs: { + sole: 'Rubber Gum', + upper: 'Canvas + Suede', + closure: 'Lace-up', + }, + weight: '520g', + size: '43', + color: 'Navy/Cream', + }, + { + name: 'Compass Proto Low White', + description: + 'All-white sneakers untuk tampilan clean. Limited edition collaboration series.', + price: 498000, + category: 'Sneakers', + sku: 'CP-PRT-WH03', + specs: { + sole: 'Rubber White', + upper: 'Leather Premium', + closure: 'Lace-up', + edition: 'Limited', + }, + weight: '480g', + size: '41', + color: 'Triple White', + }, + ], + }, + { + name: 'Jamu Iboe', + description: + 'Jamu tradisional Indonesia sejak 1910. Warisan kesehatan leluhur dalam kemasan modern.', + category: 'Health & Wellness', + user: { + email: 'marketing@jamuiboe.com', + password: 'jamu2024', + name: 'Dewi Kartika', + }, + products: [ + { + name: 'Jamu Kunyit Asam', + description: + 'Jamu klasik untuk kesehatan pencernaan dan kecantikan kulit. Terbuat dari kunyit pilihan.', + price: 15000, + category: 'Traditional Herbal', + sku: 'JI-KYA-001', + specs: { + ingredients: 'Kunyit, Asam Jawa', + benefits: 'Digestive Health', + form: 'Liquid', + }, + weight: '150ml', + }, + { + name: 'Jamu Beras Kencur', + description: + 'Jamu penambah nafsu makan dan penghilang pegal linu. Resep tradisional Jawa.', + price: 15000, + category: 'Traditional Herbal', + sku: 'JI-BKC-002', + specs: { + ingredients: 'Beras, Kencur', + benefits: 'Appetite Booster', + form: 'Liquid', + }, + weight: '150ml', + }, + { + name: 'Tolak Angin', + description: + 'Jamu untuk masuk angin dan perut kembung. Dipercaya turun temurun.', + price: 8000, + category: 'Traditional Herbal', + sku: 'JI-TLA-003', + specs: { + ingredients: 'Jahe, Madu, Mint', + benefits: 'Cold Relief', + form: 'Sachet', + }, + weight: '15ml', + }, + { + name: 'Kapsul Temulawak', + description: + 'Ekstrak temulawak dalam bentuk kapsul praktis. Untuk kesehatan liver.', + price: 45000, + category: 'Herbal Supplement', + sku: 'JI-TML-004', + specs: { + ingredients: 'Temulawak Extract 500mg', + form: 'Capsule', + quantity: '30 caps', + }, + weight: '50g', + }, + ], + }, + { + name: 'Tas Nama', + description: + 'Brand tas lokal dengan bahan ramah lingkungan. Desain fungsional untuk urban lifestyle.', + category: 'Bags & Accessories', + user: { + email: 'hello@tasnama.id', + password: 'nama2024', + name: 'Rina Wijaya', + }, + products: [ + { + name: 'Backpack Voyager 25L', + description: + 'Tas ransel untuk daily commute dengan laptop sleeve 15 inch. Water-resistant fabric.', + price: 389000, + category: 'Backpack', + sku: 'TN-VYG-25L', + specs: { + capacity: '25L', + laptop: 'Up to 15"', + material: 'Recycled Polyester', + waterproof: 'Water-resistant', + }, + weight: '750g', + color: 'Charcoal Grey', + }, + { + name: 'Tote Bag Canvas Classic', + description: + 'Tote bag canvas tebal dengan inner pocket. Cocok untuk belanja dan jalan santai.', + price: 159000, + category: 'Tote Bag', + sku: 'TN-TBC-001', + specs: { + material: 'Canvas 12oz', + closure: 'Open Top', + pockets: '1 inner, 1 outer', + }, + weight: '350g', + color: 'Natural/Brown', + }, + { + name: 'Sling Bag Mini', + description: + 'Sling bag compact untuk membawa essentials. Tali adjustable dengan quick-release buckle.', + price: 249000, + category: 'Sling Bag', + sku: 'TN-SLM-002', + specs: { + capacity: '2L', + material: 'Cordura Nylon', + closure: 'YKK Zipper', + }, + weight: '220g', + color: 'Black', + }, + ], + }, +]; + +// ============================================================================ +// DISTRIBUTION & LOCATION DATA +// ============================================================================ + +const DISTRIBUTION_REGIONS = [ + { + region: 'Jawa', + country: 'ID', + channel: 'Official Store', + market: 'Domestic', + }, + { + region: 'Sumatera', + country: 'ID', + channel: 'Authorized Retailer', + market: 'Domestic', + }, + { + region: 'Kalimantan', + country: 'ID', + channel: 'Marketplace Partner', + market: 'Domestic', + }, + { + region: 'Sulawesi', + country: 'ID', + channel: 'Distributor', + market: 'Domestic', + }, + { + region: 'Bali & Nusa Tenggara', + country: 'ID', + channel: 'Retail Partner', + market: 'Domestic', + }, + { + region: 'Southeast Asia', + country: 'SG', + channel: 'Export Partner', + market: 'Export', + }, + { + region: 'Asia Pacific', + country: 'MY', + channel: 'Regional Distributor', + market: 'Export', + }, +]; + +const INDONESIAN_LOCATIONS = [ + { + name: 'Jakarta Pusat, DKI Jakarta', + lat: -6.1751, + lng: 106.865, + country: 'ID', + }, + { name: 'Surabaya, Jawa Timur', lat: -7.2575, lng: 112.7521, country: 'ID' }, + { name: 'Bandung, Jawa Barat', lat: -6.9175, lng: 107.6191, country: 'ID' }, + { name: 'Medan, Sumatera Utara', lat: 3.5952, lng: 98.6722, country: 'ID' }, + { name: 'Semarang, Jawa Tengah', lat: -6.9666, lng: 110.4196, country: 'ID' }, + { + name: 'Makassar, Sulawesi Selatan', + lat: -5.1477, + lng: 119.4327, + country: 'ID', + }, + { name: 'Yogyakarta, DIY', lat: -7.7956, lng: 110.3695, country: 'ID' }, + { name: 'Denpasar, Bali', lat: -8.6705, lng: 115.2126, country: 'ID' }, + { + name: 'Palembang, Sumatera Selatan', + lat: -2.9761, + lng: 104.7754, + country: 'ID', + }, + { + name: 'Balikpapan, Kalimantan Timur', + lat: -1.2379, + lng: 116.8529, + country: 'ID', + }, + { name: 'Malang, Jawa Timur', lat: -7.9666, lng: 112.6326, country: 'ID' }, + { name: 'Solo, Jawa Tengah', lat: -7.5755, lng: 110.8243, country: 'ID' }, +]; + +const SUSPICIOUS_LOCATIONS = [ + { name: 'Lagos, Nigeria', lat: 6.5244, lng: 3.3792, country: 'NG' }, + { name: 'Shenzhen, China', lat: 22.5431, lng: 114.0579, country: 'CN' }, + { name: 'Moscow, Russia', lat: 55.7558, lng: 37.6173, country: 'RU' }, + { name: 'Mumbai, India', lat: 19.076, lng: 72.8777, country: 'IN' }, + { name: 'Dubai, UAE', lat: 25.2048, lng: 55.2708, country: 'AE' }, +]; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function randomElement(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function generateCode(prefix: string, length: number = 8): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let code = prefix; + for (let i = 0; i < length; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; +} + +function generateHashTx(): string { + const chars = 'abcdef0123456789'; + let hash = '0x'; + for (let i = 0; i < 64; i++) { + hash += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return hash; +} + +function generateFingerprint(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let fp = ''; + for (let i = 0; i < 32; i++) { + fp += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return fp; +} + +function generateIP(): string { + return `${randomInt(1, 255)}.${randomInt(0, 255)}.${randomInt(0, 255)}.${randomInt(1, 254)}`; +} + +function daysAgo(days: number): Date { + return new Date(Date.now() - days * 24 * 60 * 60 * 1000); +} + +function hoursAgo(hours: number): Date { + return new Date(Date.now() - hours * 60 * 60 * 1000); +} + +function minutesAgo(minutes: number): Date { + return new Date(Date.now() - minutes * 60 * 1000); +} + +const USER_AGENTS = [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Linux; Android 14; Xiaomi 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', +]; + +const BOT_USER_AGENTS = [ + 'python-requests/2.28.0', + 'curl/7.84.0', + 'Java/1.8.0_321', + 'Go-http-client/1.1', +]; + +// ============================================================================ +// QR CODE GENERATION +// ============================================================================ + +async function generateQRCodeBuffer(tagCode: string): Promise { + return QRCode.toBuffer(tagCode, { + type: 'png', + width: 512, + margin: 2, + errorCorrectionLevel: 'H', + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }); +} + +async function uploadTagAssets( + tagCode: string, + metadata: object +): Promise<{ qrUrl: string; metadataUrl: string } | null> { + if (!UPLOAD_TO_R2 || !uploadFile || !getFileUrl) { + return null; + } + + try { + // Generate and upload QR code + const qrBuffer = await generateQRCodeBuffer(tagCode); + const qrKey = `tags/${tagCode}/qr-code.png`; + await uploadFile(qrKey, qrBuffer, 'image/png'); + const qrUrl = getFileUrl(qrKey); + + // Upload metadata JSON + const metadataBuffer = Buffer.from( + JSON.stringify(metadata, null, 2), + 'utf-8' + ); + const metadataKey = `tags/${tagCode}/metadata.json`; + await uploadFile(metadataKey, metadataBuffer, 'application/json'); + const metadataUrl = getFileUrl(metadataKey); + + return { qrUrl, metadataUrl }; + } catch (error) { + console.error(`Failed to upload assets for ${tagCode}:`, error); + return null; + } +} + +// ============================================================================ +// SUSPICIOUS SCAN PATTERNS +// ============================================================================ + +type ScanData = { + fingerprint_id: string; + ip_address: string; + user_agent: string; + latitude: number | null; + longitude: number | null; + location_name: string | null; + is_claimed: number; + is_first_hand: number | null; + source_info: string | null; + scan_number: number; + created_at: Date; +}; + +type FraudPattern = { + name: string; + description: string; + generateScans: () => ScanData[]; +}; + +const FRAUD_PATTERNS: FraudPattern[] = [ + { + name: 'impossible_travel', + description: 'Same device in Jakarta and Shenzhen within 2 hours', + generateScans: () => { + const fingerprint = generateFingerprint(); + return [ + { + fingerprint_id: fingerprint, + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: -6.1751, + longitude: 106.865, + location_name: 'Jakarta Pusat, DKI Jakarta', + is_claimed: 1, + is_first_hand: 1, + source_info: null, + scan_number: 1, + created_at: hoursAgo(3), + }, + { + fingerprint_id: fingerprint, // Same device! + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: 22.5431, + longitude: 114.0579, + location_name: 'Shenzhen, China', + is_claimed: 0, + is_first_hand: null, + source_info: null, + scan_number: 2, + created_at: hoursAgo(1), + }, + ]; + }, + }, + { + name: 'high_volume_single_device', + description: '40+ scans from same device in 24 hours', + generateScans: () => { + const fingerprint = generateFingerprint(); + const ip = generateIP(); + const location = randomElement(INDONESIAN_LOCATIONS); + const scans: ScanData[] = []; + + for (let i = 0; i < randomInt(35, 50); i++) { + scans.push({ + fingerprint_id: fingerprint, + ip_address: ip, + user_agent: randomElement(USER_AGENTS), + latitude: location.lat + (Math.random() - 0.5) * 0.01, + longitude: location.lng + (Math.random() - 0.5) * 0.01, + location_name: location.name, + is_claimed: i === 0 ? 1 : 0, + is_first_hand: i === 0 ? 1 : null, + source_info: null, + scan_number: i + 1, + created_at: hoursAgo(randomInt(1, 24)), + }); + } + return scans; + }, + }, + { + name: 'multiple_claim_attempts', + description: 'Multiple devices attempting to claim same tag', + generateScans: () => { + const scans: ScanData[] = []; + for (let i = 0; i < randomInt(4, 7); i++) { + const location = randomElement(INDONESIAN_LOCATIONS); + scans.push({ + fingerprint_id: generateFingerprint(), // Different device each time + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: location.lat, + longitude: location.lng, + location_name: location.name, + is_claimed: 1, // All trying to claim! + is_first_hand: Math.random() > 0.5 ? 1 : 0, + source_info: randomElement([ + 'Tokopedia', + 'Shopee', + 'Facebook', + 'Teman', + 'Pasar', + ]), + scan_number: i + 1, + created_at: daysAgo(randomInt(0, 5)), + }); + } + return scans; + }, + }, + { + name: 'location_mismatch', + description: 'Product for Indonesia market scanned in Nigeria', + generateScans: () => { + const location = SUSPICIOUS_LOCATIONS[0]; // Lagos + return [ + { + fingerprint_id: generateFingerprint(), + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: location.lat, + longitude: location.lng, + location_name: location.name, + is_claimed: 1, + is_first_hand: 0, + source_info: 'Bought from local market', + scan_number: 1, + created_at: daysAgo(randomInt(5, 20)), + }, + { + fingerprint_id: generateFingerprint(), + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: location.lat + 0.01, + longitude: location.lng + 0.01, + location_name: location.name, + is_claimed: 0, + is_first_hand: null, + source_info: null, + scan_number: 2, + created_at: daysAgo(randomInt(0, 3)), + }, + ]; + }, + }, + { + name: 'bot_like_behavior', + description: 'Rapid automated scans with bot user-agent', + generateScans: () => { + const fingerprint = generateFingerprint(); + const scans: ScanData[] = []; + + for (let i = 0; i < randomInt(15, 25); i++) { + scans.push({ + fingerprint_id: fingerprint, + ip_address: generateIP(), + user_agent: randomElement(BOT_USER_AGENTS), + latitude: null, // Bots often don't have location + longitude: null, + location_name: null, + is_claimed: 0, + is_first_hand: null, + source_info: null, + scan_number: i + 1, + created_at: minutesAgo(i * 2), // Every 2 minutes + }); + } + return scans; + }, + }, + { + name: 'rapid_location_change', + description: 'Multiple cities in Indonesia within hours', + generateScans: () => { + const fingerprint = generateFingerprint(); + const locations = [ + INDONESIAN_LOCATIONS[0], // Jakarta + INDONESIAN_LOCATIONS[1], // Surabaya + INDONESIAN_LOCATIONS[7], // Denpasar + ]; + + return locations.map((loc, i) => ({ + fingerprint_id: fingerprint, + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: loc.lat, + longitude: loc.lng, + location_name: loc.name, + is_claimed: i === 0 ? 1 : 0, + is_first_hand: i === 0 ? 1 : null, + source_info: null, + scan_number: i + 1, + created_at: hoursAgo(6 - i * 2), // 2 hours apart + })); + }, + }, +]; + +// ============================================================================ +// LEGITIMATE SCAN PATTERN +// ============================================================================ + +function generateLegitimateScans( + numScans: number, + distribution: (typeof DISTRIBUTION_REGIONS)[0] +): ScanData[] { + const scans: ScanData[] = []; + const claimerFingerprint = generateFingerprint(); + + // Get appropriate locations based on distribution region + const locations = INDONESIAN_LOCATIONS.filter((loc) => { + if (distribution.region === 'Jawa') { + return ( + loc.name.includes('Jakarta') || + loc.name.includes('Jawa') || + loc.name.includes('Yogyakarta') + ); + } + if (distribution.region === 'Bali & Nusa Tenggara') { + return loc.name.includes('Bali'); + } + return true; + }); + + for (let i = 0; i < numScans; i++) { + const location = randomElement(locations); + const isClaim = i === 0; + + scans.push({ + fingerprint_id: isClaim ? claimerFingerprint : generateFingerprint(), + ip_address: generateIP(), + user_agent: randomElement(USER_AGENTS), + latitude: location.lat + (Math.random() - 0.5) * 0.05, + longitude: location.lng + (Math.random() - 0.5) * 0.05, + location_name: location.name, + is_claimed: isClaim ? 1 : 0, + is_first_hand: isClaim ? 1 : null, + source_info: isClaim + ? randomElement([ + 'Official Store', + 'Tokopedia Official', + 'Shopee Mall', + null, + ]) + : null, + scan_number: i + 1, + created_at: daysAgo(randomInt(1, 60)), + }); + } + + return scans.sort((a, b) => a.created_at.getTime() - b.created_at.getTime()); +} + +// ============================================================================ +// MAIN SEED FUNCTION +// ============================================================================ + +async function main() { + console.log('========================================'); + console.log(' ETAGS COMPLETE DATABASE SEEDING'); + console.log('========================================\n'); + + console.log('Options:'); + console.log(` - Upload to R2: ${UPLOAD_TO_R2 ? 'YES' : 'NO'}`); + console.log(` - Clean data: ${CLEAN_DATA ? 'YES' : 'NO'}`); + console.log(''); + + await initR2(); + + // Clean existing data if requested + if (CLEAN_DATA) { + console.log('Cleaning existing data...'); + await prisma.tagScan.deleteMany(); + await prisma.tag.deleteMany(); + await prisma.product.deleteMany(); + await prisma.user.deleteMany({ where: { role: 'brand' } }); + await prisma.brand.deleteMany(); + console.log('Existing data cleaned.\n'); + } + + let totalBrands = 0; + let totalUsers = 0; + let totalProducts = 0; + let totalTags = 0; + let totalScans = 0; + let suspiciousTags = 0; + + // Placeholder image service + const getPlaceholderImage = ( + seed: string, + width: number = 400, + height: number = 400 + ) => `https://picsum.photos/seed/${seed}/${width}/${height}`; + + // Create brands with users, products, and tags + for (const brandData of BRANDS) { + console.log(`\nCreating brand: ${brandData.name}`); + + // Create brand + const brand = await prisma.brand.create({ + data: { + name: brandData.name, + descriptions: brandData.description, + logo_url: getPlaceholderImage( + brandData.name.toLowerCase().replace(/\s/g, '-'), + 200, + 200 + ), + status: 1, + }, + }); + totalBrands++; + + // Create brand user + const hashedPassword = await bcrypt.hash(brandData.user.password, 10); + const user = await prisma.user.create({ + data: { + name: brandData.user.name, + email: brandData.user.email, + password: hashedPassword, + role: 'brand', + status: 1, + brand_id: brand.id, + onboarding_complete: 1, + }, + }); + totalUsers++; + console.log(` User: ${user.email}`); + + // Create products + for (const productData of brandData.products) { + const productCode = generateCode('PRD-'); + + const product = await prisma.product.create({ + data: { + code: productCode, + brand_id: brand.id, + status: 1, + metadata: { + _template: 'generic', + name: productData.name, + description: productData.description, + price: productData.price, + category: productData.category, + sku: productData.sku, + specifications: productData.specs, + weight: productData.weight, + color: productData.color, + color_name: productData.color, + size: productData.size, + material: productData.material, + images: [ + getPlaceholderImage(`${productCode}-1`), + getPlaceholderImage(`${productCode}-2`), + getPlaceholderImage(`${productCode}-3`), + ], + }, + }, + }); + totalProducts++; + + // Create 2-4 tags per product + const numTags = randomInt(2, 4); + for (let t = 0; t < numTags; t++) { + const tagCode = generateCode('TAG-', 10); + const distribution = randomElement(DISTRIBUTION_REGIONS); + + // Determine if this tag will be suspicious (15% chance) + const isSuspicious = Math.random() < 0.15; + const fraudPattern = isSuspicious + ? randomElement(FRAUD_PATTERNS) + : null; + + // Determine tag status + const isStamped = Math.random() > 0.1; // 90% stamped + const chainStatus = isStamped + ? isSuspicious + ? 4 // FLAGGED + : randomElement([1, 1, 1, 2, 2, 2, 2, 3]) // Mostly DISTRIBUTED or CLAIMED + : 0; + + const tagMetadata = { + notes: `Tag for ${productData.name}`, + batch_number: generateCode('BATCH-', 6), + manufacture_date: daysAgo(randomInt(30, 180)) + .toISOString() + .split('T')[0], + distribution_region: distribution.region, + distribution_country: distribution.country, + distribution_channel: distribution.channel, + intended_market: distribution.market, + }; + + const tag = await prisma.tag.create({ + data: { + code: tagCode, + product_ids: [product.id], + metadata: tagMetadata, + is_stamped: isStamped ? 1 : 0, + publish_status: 1, + chain_status: chainStatus, + hash_tx: isStamped ? generateHashTx() : null, + }, + }); + totalTags++; + + // Upload to R2 if enabled + if (UPLOAD_TO_R2 && isStamped) { + const fullMetadata = { + version: '1.0', + tag: { + code: tagCode, + created_at: tag.created_at.toISOString(), + stamped_at: new Date().toISOString(), + metadata: tagMetadata, + }, + products: [ + { + id: product.id, + code: product.code, + name: productData.name, + description: productData.description, + images: [], + brand: { + id: brand.id, + name: brand.name, + logo_url: brand.logo_url, + }, + }, + ], + distribution: { + region: distribution.region, + country: distribution.country, + channel: distribution.channel, + intended_market: distribution.market, + }, + verification: { + qr_code_url: '', + verify_url: `https://etags.app/verify/${tagCode}`, + blockchain: { + network: 'Base Sepolia', + chain_id: 84532, + contract_address: process.env.CONTRACT_ADDRESS || '0x...', + transaction_hash: tag.hash_tx, + }, + }, + }; + + await uploadTagAssets(tagCode, fullMetadata); + } + + // Generate scans + if (chainStatus >= 1) { + const scans = + isSuspicious && fraudPattern + ? fraudPattern.generateScans() + : generateLegitimateScans(randomInt(1, 5), distribution); + + for (const scanData of scans) { + await prisma.tagScan.create({ + data: { + tag_id: tag.id, + ...scanData, + }, + }); + totalScans++; + } + + if (isSuspicious) { + suspiciousTags++; + console.log(` [SUSPICIOUS] ${tagCode}: ${fraudPattern?.name}`); + } + } + } + } + console.log(` Products: ${brandData.products.length}, Tags created`); + } + + // Create admin user if not exists + const adminExists = await prisma.user.findUnique({ + where: { email: 'admin@etags.app' }, + }); + + if (!adminExists) { + const adminPassword = await bcrypt.hash('admin2024', 10); + await prisma.user.create({ + data: { + name: 'Super Admin', + email: 'admin@etags.app', + password: adminPassword, + role: 'admin', + status: 1, + onboarding_complete: 1, + }, + }); + console.log('\nAdmin user created: admin@etags.app / admin2024'); + } + + // Summary + console.log('\n========================================'); + console.log(' SEEDING COMPLETE!'); + console.log('========================================'); + console.log(`\n Brands: ${totalBrands}`); + console.log(` Brand Users: ${totalUsers}`); + console.log(` Products: ${totalProducts}`); + console.log(` Tags: ${totalTags}`); + console.log(` Scans: ${totalScans}`); + console.log( + ` Suspicious Tags: ${suspiciousTags} (flagged for AI detection)` + ); + + if (UPLOAD_TO_R2) { + console.log('\n R2 Assets uploaded for stamped tags'); + } + + console.log('\n Brand User Credentials:'); + for (const brand of BRANDS) { + console.log(` - ${brand.user.email} / ${brand.user.password}`); + } + + // Tag status distribution + console.log('\n Tag Status Distribution:'); + const statusCounts = await prisma.tag.groupBy({ + by: ['chain_status'], + _count: true, + }); + + const STATUS_NAMES = [ + 'CREATED', + 'DISTRIBUTED', + 'CLAIMED', + 'TRANSFERRED', + 'FLAGGED', + 'REVOKED', + ]; + for (const stat of statusCounts) { + const statusName = STATUS_NAMES[stat.chain_status ?? 0] || 'UNKNOWN'; + console.log(` ${statusName}: ${stat._count}`); + } +} + +main() + .catch((e) => { + console.error('\nSeeding failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); From c17c18479ee0180d3bba8885954120464adde4ab Mon Sep 17 00:00:00 2001 From: Indra Gunanda Date: Tue, 2 Dec 2025 23:50:56 +0700 Subject: [PATCH 04/37] feat: Implement statistics cards and scan location maps for management pages, and refresh form styling. --- .env.example | 3 + CLAUDE.md | 16 +- package-lock.json | 379 +++++++++++++++--- package.json | 2 + src/app/manage/brands/brand-stats-cards.tsx | 103 +++++ src/app/manage/brands/page.tsx | 26 +- src/app/manage/my-brand/brand-info-form.tsx | 43 +- src/app/manage/my-brand/brand-logo-form.tsx | 47 ++- src/app/manage/products/page.tsx | 26 +- .../manage/products/product-stats-cards.tsx | 101 +++++ src/app/manage/profile/avatar-form.tsx | 43 +- src/app/manage/profile/password-form.tsx | 51 ++- src/app/manage/profile/profile-form.tsx | 45 ++- src/app/manage/tags/page.tsx | 44 +- src/app/manage/tags/scan-map-section.tsx | 76 ++++ src/app/manage/tags/scan-stats-cards.tsx | 99 +++++ src/app/manage/users/page.tsx | 27 +- src/app/manage/users/user-stats-cards.tsx | 97 +++++ src/components/maps/scan-location-map.tsx | 256 ++++++++++++ src/lib/actions/brands.ts | 28 ++ src/lib/actions/products.ts | 48 +++ src/lib/actions/tags.ts | 118 ++++++ src/lib/actions/users.ts | 20 + 23 files changed, 1577 insertions(+), 121 deletions(-) create mode 100644 src/app/manage/brands/brand-stats-cards.tsx create mode 100644 src/app/manage/products/product-stats-cards.tsx create mode 100644 src/app/manage/tags/scan-map-section.tsx create mode 100644 src/app/manage/tags/scan-stats-cards.tsx create mode 100644 src/app/manage/users/user-stats-cards.tsx create mode 100644 src/components/maps/scan-location-map.tsx diff --git a/.env.example b/.env.example index 25df986..6cf0111 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,6 @@ KOLOSAL_API_KEY="your_kolosal_api_key" # BaseScan API (for explorer) BASESCAN_API_KEY="your_basescan_api_key" + +# Mapbox (for scan location maps) +NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN="your_mapbox_access_token" diff --git a/CLAUDE.md b/CLAUDE.md index 0b0d2b1..cd26c48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,8 +21,9 @@ Etags is a Next.js 16 application for product tagging and blockchain stamping. I - `npm run test` - Run tests in watch mode - `npm run test -- --run` - Run tests once (CI mode) - `npm run test -- --coverage` - Run tests with coverage +- `npm run test -- src/lib/actions/auth.test.ts` - Run a single test file -Test files use `*.test.{ts,tsx}` naming convention and are located throughout `src/`. +Test files use `*.test.{ts,tsx}` naming convention and are located throughout `src/`. Test setup is in `src/tests/setup.ts`. ### Database (Prisma with MySQL) @@ -32,6 +33,11 @@ Test files use `*.test.{ts,tsx}` naming convention and are located throughout `s - `npm run db:studio` - Open Prisma Studio GUI - `npm run db:create-admin` - Create admin user (default: admin@example.com / admin123) - `npm run db:create-admin -- email@example.com password123 "Name"` - Create admin with custom credentials +- `npm run db:seed` - Seed basic sample data +- `npm run db:seed-fraud` - Add fraud scan patterns to existing tags +- `npm run db:seed-complete` - Complete seed with brands, users, products, tags, and suspicious scans +- `npm run db:seed-complete -- --upload-r2` - Same as above but uploads QR codes to R2 +- `npm run db:seed-complete -- --clean` - Clean existing data before seeding ## Architecture @@ -109,6 +115,7 @@ Server actions are organized in `src/lib/actions/`: - `/api/csrf` - CSRF token endpoint - `/api/tags/[code]/designed` - Get designed QR code for tag - `/api/tags/template-preview` - Preview QR template designs +- `/api/ai-agent` - AI agent chat endpoint for dashboard ### Database Schema @@ -153,7 +160,11 @@ Runs `typecheck` and `lint-staged` (which runs Prettier on staged files) before ### CI/CD (GitHub Actions) -Runs on push to `master` and PRs to `develop`, `feature/*`, `fix/*`. Pipeline: lint → typecheck → test → build. +Runs on push to `master` and PRs targeting `develop`, `feature/*`, `fix/*`. Pipeline: lint → typecheck → test → build. + +### Smart Contracts + +Solidity contracts are in `smartcontracts/` directory with separate Hardhat setup. See `smartcontracts/README.md` for contract development and testing. ## Environment Variables @@ -168,3 +179,4 @@ Copy `.env.example` to `.env` and configure: - `BLOCKCHAIN_EXPLORER_URL` - Block explorer URL (default: Base Sepolia) - `KOLOSAL_API_KEY` - Kolosal AI for fraud detection - `BASESCAN_API_KEY` - BaseScan API for explorer features +- `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` - Mapbox token for scan location maps diff --git a/package-lock.json b/package-lock.json index 9820494..b4d9407 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,14 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@types/mapbox-gl": "^3.4.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "ethers": "^6.13.4", "html5-qrcode": "^2.3.8", "lucide-react": "^0.555.0", + "mapbox-gl": "^3.16.0", "next": "16.0.6", "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", @@ -1291,7 +1293,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1301,7 +1303,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1438,7 +1440,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2977,6 +2979,58 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "license": "MIT" }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", + "integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==", + "license": "BSD-3-Clause" + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3241,7 +3295,7 @@ "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz", "integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -3254,14 +3308,14 @@ "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz", "integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz", "integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -3275,14 +3329,14 @@ "version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz", "integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz", "integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.0", @@ -3294,7 +3348,7 @@ "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz", "integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.0" @@ -5881,7 +5935,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@swagger-api/apidom-ast": { @@ -6938,6 +6992,21 @@ "@types/estree": "*" } }, + "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/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -6960,6 +7029,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox-gl": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz", + "integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -6985,6 +7069,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.5", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", @@ -7014,6 +7104,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -7023,7 +7114,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -7039,6 +7130,15 @@ "@types/node": "*" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/swagger-jsdoc": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", @@ -8207,7 +8307,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.26.0" @@ -8382,7 +8482,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -8593,11 +8693,17 @@ "dev": true, "license": "MIT" }, + "node_modules/cheap-ruler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", + "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", + "license": "ISC" + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -8613,7 +8719,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -8903,14 +9009,14 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -9032,6 +9138,12 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "license": "MIT" }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -9064,6 +9176,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, "license": "MIT" }, "node_modules/cz-conventional-changelog": { @@ -9322,7 +9435,7 @@ "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, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -9380,7 +9493,7 @@ "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/delayed-stream": { @@ -9405,7 +9518,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/detect-file": { @@ -9495,7 +9608,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -9527,11 +9640,17 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, "node_modules/effect": { "version": "3.18.4", "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -9556,7 +9675,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -10395,7 +10514,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/extend": { @@ -10423,7 +10542,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -10838,6 +10957,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -10941,7 +11066,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -10955,6 +11080,12 @@ "giget": "dist/cli.mjs" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -11115,6 +11246,12 @@ "dev": true, "license": "MIT" }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -12198,7 +12335,7 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -12383,6 +12520,12 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13197,20 +13340,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -13240,6 +13369,45 @@ "node": ">=10" } }, + "node_modules/mapbox-gl": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.16.0.tgz", + "integrity": "sha512-rluV1Zp/0oHf1Y9BV+nePRNnKyTdljko3E19CzO5rBqtQaNUYS0ePCMPRtxOuWRwSdKp3f9NWJkOCjemM8nmjw==", + "license": "SEE LICENSE IN LICENSE.txt", + "workspaces": [ + "src/style-spec", + "test/build/typings" + ], + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^3.0.0", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "^3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "cheap-ruler": "^4.0.0", + "csscolorparser": "~1.0.3", + "earcut": "^3.0.1", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "grid-index": "^1.1.0", + "kdbush": "^4.0.2", + "martinez-polygon-clipping": "^0.7.4", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "serialize-to-js": "^3.1.2", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -13250,6 +13418,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/martinez-polygon-clipping": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.7.4.tgz", + "integrity": "sha512-jBEwrKtA0jTagUZj2bnmb4Yg2s4KnJGRePStgI7bAVjtcipKiF39R4LZ2V/UT61jMYWrTcBhPazexeqd6JAVtw==", + "license": "MIT", + "dependencies": { + "robust-predicates": "^2.0.4", + "splaytree": "^0.1.4", + "tinyqueue": "^1.2.0" + } + }, + "node_modules/martinez-polygon-clipping/node_modules/tinyqueue": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-1.2.3.tgz", + "integrity": "sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==", + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -14226,6 +14411,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -14470,7 +14661,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-gyp-build": { @@ -14496,7 +14687,7 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -14658,7 +14849,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/once": { @@ -14710,13 +14901,6 @@ "node": ">=12.20.0" } }, - "node_modules/openapi-types": { - "version": "12.1.3", - "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", - "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", - "license": "MIT", - "peer": true - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -14948,14 +15132,26 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -14994,7 +15190,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -15063,6 +15259,12 @@ "node": ">=4" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/preact": { "version": "10.24.3", "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", @@ -15147,7 +15349,7 @@ "version": "6.19.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz", "integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -15208,6 +15410,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -15228,7 +15436,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -15285,6 +15493,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/ramda": { "version": "0.30.1", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", @@ -15337,7 +15551,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -15562,7 +15776,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -15839,6 +16053,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -15880,6 +16103,12 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==", + "license": "Unlicense" + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", @@ -16104,6 +16333,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serialize-to-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-3.1.2.tgz", + "integrity": "sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -16432,6 +16670,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/splaytree": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz", + "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==", + "license": "MIT" + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -16728,6 +16972,15 @@ } } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17015,7 +17268,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -17069,6 +17322,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", @@ -17426,7 +17685,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 72049d6..471b8b2 100644 --- a/package.json +++ b/package.json @@ -42,12 +42,14 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@types/mapbox-gl": "^3.4.1", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "ethers": "^6.13.4", "html5-qrcode": "^2.3.8", "lucide-react": "^0.555.0", + "mapbox-gl": "^3.16.0", "next": "16.0.6", "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", diff --git a/src/app/manage/brands/brand-stats-cards.tsx b/src/app/manage/brands/brand-stats-cards.tsx new file mode 100644 index 0000000..b6298ed --- /dev/null +++ b/src/app/manage/brands/brand-stats-cards.tsx @@ -0,0 +1,103 @@ +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Building2, CheckCircle, Package, FolderOpen } from 'lucide-react'; + +type BrandStatsCardsProps = { + stats: { + totalBrands: number; + activeBrands: number; + totalProducts: number; + brandsWithProducts: number; + }; +}; + +export function BrandStatsCards({ stats }: BrandStatsCardsProps) { + return ( +
+ +
+ + + Total Brand + +
+ +
+
+ +
+ {stats.totalBrands} +
+ + Brand terdaftar + +
+ + + +
+ + + Brand Aktif + +
+ +
+
+ +
+ {stats.activeBrands} +
+ + Brand aktif + +
+ + + +
+ + + Total Produk + +
+ +
+
+ +
+ {stats.totalProducts} +
+ + Produk terdaftar + +
+ + + +
+ + + Dengan Produk + +
+ +
+
+ +
+ {stats.brandsWithProducts} +
+ + Brand punya produk + +
+ +
+ ); +} diff --git a/src/app/manage/brands/page.tsx b/src/app/manage/brands/page.tsx index 305cd22..0365853 100644 --- a/src/app/manage/brands/page.tsx +++ b/src/app/manage/brands/page.tsx @@ -1,16 +1,33 @@ import { auth } from '@/lib/auth'; import { redirect } from 'next/navigation'; -import { getBrands } from '@/lib/actions/brands'; +import { getBrands, getBrandStats } from '@/lib/actions/brands'; import { BrandsTable } from './brands-table'; import { BrandsHeader } from './brands-header'; +import { BrandStatsCards } from './brand-stats-cards'; import { Suspense } from 'react'; import { TableSkeleton } from '../table-skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; async function BrandsTableWrapper() { const { brands } = await getBrands(1, 50); return ; } +async function BrandStatsWrapper() { + const stats = await getBrandStats(); + return ; +} + +function StatsCardsSkeleton() { + return ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ ); +} + export default async function BrandsPage() { const session = await auth(); @@ -21,6 +38,13 @@ export default async function BrandsPage() { return (
+ + {/* Stats Cards */} + }> + + + + {/* Brands Table */}
}> diff --git a/src/app/manage/my-brand/brand-info-form.tsx b/src/app/manage/my-brand/brand-info-form.tsx index a99545e..26eee40 100644 --- a/src/app/manage/my-brand/brand-info-form.tsx +++ b/src/app/manage/my-brand/brand-info-form.tsx @@ -13,6 +13,7 @@ import { CardTitle, } from '@/components/ui/card'; import { updateMyBrand, type MyBrandFormState } from '@/lib/actions/my-brand'; +import { Building2 } from 'lucide-react'; type BrandInfoFormProps = { brand: { @@ -31,25 +32,42 @@ export function BrandInfoForm({ brand }: BrandInfoFormProps) { >(updateMyBrand, {}); return ( - - - Informasi Brand - Perbarui detail brand Anda + +
+ +
+
+ +
+
+ + Informasi Brand + + + Perbarui detail brand Anda + +
+
- +
- +
- +