diff --git a/.env.example b/.env.example index 25df986..1f98b6c 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,21 @@ BLOCKCHAIN_RPC_URL="https://sepolia.base.org" BLOCKCHAIN_EXPLORER_URL="https://sepolia.basescan.org" BLOCKCHAIN_NETWORK="Base Sepolia" CONTRACT_ADDRESS="0x2b9D6D96E2538f351931A4f35Ce4A5A072f879d5" +NFT_CONTRACT_ADDRESS="your_nft_contract_address" +NEXT_PUBLIC_NFT_CONTRACT_ADDRESS="your_nft_contract_address" CHAIN_ID="84532" ADMIN_WALLET="your_private_key" +# Optional: Set owner address different from deployer (for contract deployment) +CONTRACT_OWNER="your_owner_wallet_address" + +# Gemini API (for NFT art generation) +GEMINI_API_KEY="your_gemini_api_key" # Kolosal AI (for fraud detection) 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/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..9a51923 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,6 @@ +# CODEOWNERS file for etags repository +# Code owners will be automatically requested for review when someone opens a PR that modifies code they own. + +# API routes +/src/app/api/** @igun997 +/src/app/manage/** @igun997 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2848cd7..483ef78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,9 @@ on: branches: ['develop', 'feature/*', 'fix/*'] jobs: - build: + lint: + name: Lint runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -25,13 +25,95 @@ jobs: - name: Run Linting run: npm run lint + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + - name: Run Typecheck run: npm run typecheck + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + - name: Run Tests - run: npm run test -- --run + run: npm run test -- --run --coverage + + - name: Upload Coverage Report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/ + retention-days: 7 + + build: + name: build + runs-on: ubuntu-latest + needs: [lint, typecheck, test] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + NEXT_TELEMETRY_DISABLED: 1 + + lighthouse: + name: Lighthouse + runs-on: ubuntu-latest + needs: [build] + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci - name: Build run: npm run build env: NEXT_TELEMETRY_DISABLED: 1 + + - name: Run Lighthouse CI + uses: treosh/lighthouse-ci-action@v12 + with: + configPath: './lighthouserc.json' + uploadArtifacts: true + temporaryPublicStorage: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..28ad4ab --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,14 @@ +name: Deploy + +on: + push: + branches: ['develop', 'master'] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Trigger Deployment + run: | + curl -f -X POST "${{ secrets.DEPLOY_WEBHOOK_URL }}" || exit 1 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/CLAUDE.md b/CLAUDE.md index 0b0d2b1..2f90b32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Etags is a Next.js 16 application for product tagging and blockchain stamping. It manages brands, products, and tags with blockchain transaction tracking for authentication/verification purposes. +**Node.js requirement:** 20.x (LTS) + ## Commands - `npm run dev` - Start development server @@ -21,8 +23,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` with mocks in `src/tests/mocks.ts`. Coverage is configured only for `src/lib/actions/**/*.ts`. ### Database (Prisma with MySQL) @@ -32,6 +35,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 @@ -62,6 +70,8 @@ Test files use `*.test.{ts,tsx}` naming convention and are located throughout `s - `@/lib/explorer.ts` - Blockchain explorer integration - `@/lib/rate-limit.ts` - Rate limiting for API endpoints - `@/lib/csrf.ts` - CSRF protection +- `@/lib/nft-collectible.ts` - NFT minting and claim processing +- `@/lib/gemini-image.ts` - Gemini API for NFT art generation ### Server Actions @@ -77,6 +87,8 @@ Server actions are organized in `src/lib/actions/`: - `onboarding.ts` - User onboarding flow - `my-brand.ts` - Brand user self-management - `ai-agent.ts` - AI agent integration +- `nfts.ts` - NFT collectible management and stats +- `support-tickets.ts` - Web3 support ticket CRUD and messaging ### Routes @@ -86,6 +98,8 @@ Server actions are organized in `src/lib/actions/`: - `/manage/brands` - Brand management - `/manage/products` - Product CRUD with `/new` and `/[id]/edit` - `/manage/tags` - Tag management with `/new` and `/[id]/edit` +- `/manage/nfts` - NFT collectible monitoring with `/[id]` detail view +- `/manage/tickets` - Support ticket management with `/[id]` detail view - `/manage/users` - User management (admin only) - `/manage/profile` - User profile settings @@ -93,10 +107,13 @@ Server actions are organized in `src/lib/actions/`: - `/` - Public landing page - `/login` - Login page (redirects to /manage if authenticated) +- `/register` - User registration page - `/scan` - QR code scanner for tag verification - `/verify/[code]` - Tag verification page with product details - `/explorer` - Blockchain transaction explorer - `/explorer/tx/[hash]` - Transaction detail page +- `/support` - Web3 support tickets (NFT holders connect wallet to submit issues) +- `/faqs` - Frequently asked questions page - `/docs` - Swagger API documentation UI **API Routes:** @@ -104,21 +121,27 @@ Server actions are organized in `src/lib/actions/`: - `/api/docs` - OpenAPI JSON spec - `/api/scan` - Tag scan endpoint (records scans with fingerprint) - `/api/scan/claim` - Claim a tag as owner +- `/api/scan/claim-nft` - Claim NFT collectible for first-hand claimers - `/api/verify` - Tag verification API - `/api/explorer` - Blockchain explorer API - `/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 Core models in `prisma/schema.prisma`: -- **User** - Admin/brand users with role-based access (`role`: admin or brand) +- **User** - Admin/brand users with role-based access (`role`: admin or brand), linked to brand via `brand_id` - **Brand** - Product brand management with logo and descriptions -- **Product** - Products with JSON metadata, linked to brands -- **Tag** - Product tags with blockchain stamping (`is_stamped`, `hash_tx`, `chain_status`) -- **TagScan** - Scan history with fingerprinting, location, and claim status +- **Product** - Products with JSON metadata (`name`, `description`, `price`, `images[]`), linked to brands +- **Tag** - Product tags with blockchain stamping (`is_stamped`, `hash_tx`, `chain_status`), stores `product_ids` as JSON array +- **TagScan** - Scan history with fingerprinting, location, claim status, and ownership tracking (`is_first_hand`, `source_info`) +- **TagNFT** - NFT collectibles minted for first-hand claimers (`token_id`, `owner_address`, `image_url`, `metadata_url`, `mint_tx_hash`) +- **SupportTicket** - Web3 support tickets linked to tags and brands (`wallet_address`, `category`, `status`, `priority`) +- **TicketMessage** - Ticket conversation thread with sender type (customer/brand/admin) +- **TicketAttachment** - File attachments for tickets stored in R2 ### Tag Blockchain Lifecycle @@ -137,10 +160,43 @@ The blockchain contract (ETagRegistry) supports: `createTag`, `updateStatus`, `r 1. User scans QR code → `/scan` page 2. Browser collects fingerprint (FingerprintJS) and location -3. POST to `/api/scan` records the scan in `TagScan` +3. POST to `/api/scan` records the scan in `TagScan` with sequential `scan_number` 4. Redirects to `/verify/[code]` showing product info -5. User can claim ownership via `/api/scan/claim` -6. AI fraud detection analyzes scan location vs distribution info +5. User can claim ownership via `/api/scan/claim` (sets `is_claimed`, asks about `is_first_hand`) +6. AI fraud detection analyzes scan patterns and location vs distribution info + +### NFT Collectible Claim Flow + +First-hand tag claimers on Web3 browsers can mint an NFT collectible: + +1. User claims tag as first-hand owner on `/verify/[code]` +2. System detects Web3 wallet (MetaMask, etc.) via `window.ethereum` +3. User connects wallet and switches to Base Sepolia (Chain ID: 84532) +4. POST to `/api/scan/claim-nft` triggers NFT minting: + - Generates unique art via Gemini API (`gemini-3-pro-image-preview`) + - Uploads image and metadata to R2: `nfts/{tagCode}/` + - Admin wallet mints NFT via ETagCollectible contract (user doesn't pay gas) + - NFT transferred directly to user's wallet +5. TagNFT record created with `token_id`, `owner_address`, `mint_tx_hash` +6. Admin monitors NFTs at `/manage/nfts` + +**Smart Contracts:** + +- `ETagCollectible.sol` - ERC721 NFT contract with one-NFT-per-tag enforcement +- Functions: `mintTo()`, `isTagMinted()`, `getTokenByTag()`, `grantMinter()`, `pause()` + +### Web3 Support Ticket Flow + +NFT holders can submit product complaints via wallet connection: + +1. User visits `/support` and connects wallet (MetaMask, etc.) +2. System queries blockchain for user's owned NFTs via ETagCollectible contract +3. User selects a product/tag and submits ticket with category (`defect`, `quality`, `missing_parts`, `warranty`, `other`) +4. Ticket routes to brand (if brand has users), otherwise to admin +5. Brand/admin responds via `/manage/tickets/[id]` +6. User views responses by reconnecting wallet at `/support` + +Ticket statuses: `open` → `in_progress` → `resolved` → `closed` ### Pre-commit Hook @@ -153,7 +209,28 @@ 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. +Triggers: + +- Push to `master` branch +- Pull requests targeting `develop`, `feature/*`, `fix/*` branches + +Pipeline: lint → typecheck → test → build + +### Smart Contracts + +Solidity contracts are in `smartcontracts/` directory with separate Hardhat setup: + +- `ETagRegistry.sol` - Main contract for tag lifecycle management (create, validate, update status, revoke) +- `ETagCollectible.sol` - ERC721 NFT contract for collectibles (one NFT per tag) + +Contract commands (run from `smartcontracts/` directory): + +- `npm run compile` - Compile contracts +- `npm run test` - Run contract tests +- `npm run deploy:local` - Deploy to local Hardhat node +- `npm run deploy:sepolia` - Deploy to Base Sepolia testnet + +See `smartcontracts/README.md` for full contract development documentation. ## Environment Variables @@ -164,7 +241,11 @@ Copy `.env.example` to `.env` and configure: - `AUTH_TRUST_HOST` - Set to `true` for production deployments - `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET` - Cloudflare R2 credentials - `R2_PUBLIC_DOMAIN` - Public URL for R2 bucket assets -- `BLOCKCHAIN_RPC_URL`, `CONTRACT_ADDRESS`, `CHAIN_ID`, `ADMIN_WALLET` - Blockchain config +- `BLOCKCHAIN_RPC_URL`, `CONTRACT_ADDRESS`, `CHAIN_ID`, `ADMIN_WALLET`, `BLOCKCHAIN_NETWORK` - Blockchain config +- `CONTRACT_OWNER` - Optional: Owner address different from deployer (for contract deployment) - `BLOCKCHAIN_EXPLORER_URL` - Block explorer URL (default: Base Sepolia) +- `NFT_CONTRACT_ADDRESS`, `NEXT_PUBLIC_NFT_CONTRACT_ADDRESS` - ETagCollectible NFT contract address +- `GEMINI_API_KEY` - Gemini API for NFT art generation - `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/Dockerfile b/Dockerfile index 2048ccb..e530df6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,11 +3,12 @@ FROM node:20-alpine AS base # Install dependencies only when needed FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat +# vips-dev is required for sharp (image processing for NFT fallback generation) +RUN apk add --no-cache libc6-compat vips-dev 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 +20,10 @@ 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 + +# Generate Prisma client before building +RUN npx prisma generate RUN npm run build @@ -27,14 +31,17 @@ RUN npm run build FROM base AS runner WORKDIR /app -ENV NODE_ENV production +# Install vips for sharp (NFT fallback image generation at runtime) +RUN apk add --no-cache vips + +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 -COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/public ./public # Set the correct permission for prerender cache RUN mkdir .next @@ -49,7 +56,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/README.md b/README.md index f999161..253b112 100644 --- a/README.md +++ b/README.md @@ -1,337 +1,167 @@ # Etags -**Platform Penandaan Produk & Stamping Blockchain** +[![Tests](https://github.com/cds-id/etags/actions/workflows/ci.yml/badge.svg)](https://github.com/cds-id/etags/actions/workflows/ci.yml) +[![Deploy - Develop](https://github.com/cds-id/etags/actions/workflows/deploy.yml/badge.svg?branch=develop)](https://github.com/cds-id/etags/actions/workflows/deploy.yml) +[![Deploy - Master](https://github.com/cds-id/etags/actions/workflows/deploy.yml/badge.svg?branch=master)](https://github.com/cds-id/etags/actions/workflows/deploy.yml) -Aplikasi untuk mengelola brand, produk, dan tag dengan pelacakan transaksi blockchain untuk tujuan autentikasi dan verifikasi keaslian produk. +**Product Tagging & Blockchain Stamping Platform** - Manage brands, products, and tags with blockchain transaction tracking for authentication and product verification. -## Demo +| | | +| ------------- | --------------------------- | +| **Demo** | https://tags.cylink.site/ | +| **Hackathon** | IMPHEN 2025 | +| **Team** | Pemuja Deadline Anti Refund | -🔗 **Live Demo:** -https://tags.cylink.site/ +[![igun997](https://img.shields.io/badge/GitHub-igun997-181717?style=flat-square&logo=github)](https://github.com/igun997) +[![inact25](https://img.shields.io/badge/GitHub-inact25-181717?style=flat-square&logo=github)](https://github.com/inact25) +[![juanaf31](https://img.shields.io/badge/GitHub-juanaf31-181717?style=flat-square&logo=github)](https://github.com/juanaf31) +[![ramaramx](https://img.shields.io/badge/GitHub-ramaramx-181717?style=flat-square&logo=github)](https://github.com/ramaramx) -## Repository - -📦 **GitHub:** https://github.com/cds-id/etags - -## Hackathon - -🏆 **IMPHEN 2025** - -## Tim - -**Pemuja Deadline Anti Refund** - -[![igun997](https://img.shields.io/badge/GitHub-igun997-181717?style=for-the-badge&logo=github)](https://github.com/igun997) -[![inact25](https://img.shields.io/badge/GitHub-inact25-181717?style=for-the-badge&logo=github)](https://github.com/inact25) -[![juanaf31](https://img.shields.io/badge/GitHub-juanaf31-181717?style=for-the-badge&logo=github)](https://github.com/juanaf31) -[![ramaramx](https://img.shields.io/badge/GitHub-ramaramx-181717?style=for-the-badge&logo=github)](https://github.com/ramaramx) +Contributors ## Tech Stack -- **Framework:** Next.js 16 (App Router) -- **Database:** MySQL + Prisma ORM -- **Authentication:** NextAuth v5 -- **Blockchain:** ethers.js -- **Storage:** Cloudflare R2 -- **AI:** Kolosal AI (Analytics & Fraud Detection) -- **Styling:** Tailwind CSS v4 - -## Fitur Utama - -- **Manajemen Brand** - Kelola informasi brand/merek produk -- **Manajemen Produk** - Katalog produk dengan metadata dinamis (JSON) -- **Manajemen Tag** - Buat dan kelola tag untuk produk -- **Blockchain Stamping** - Catat tag ke blockchain untuk verifikasi keaslian -- **Pelacakan Status** - Lacak siklus hidup tag (Created → Distributed → Claimed → Transferred) -- **AI Agent Dashboard** - Asisten AI untuk analisis data dan insights (Admin & Brand) -- **Fraud Detection** - Deteksi kecurangan menggunakan AI pada scan tag -- **Upload File** - Simpan gambar dan file ke Cloudflare R2 -- **API Documentation** - Swagger UI untuk dokumentasi API - -## AI Agent - -Etags dilengkapi dengan **AI Agent** yang tersedia di dashboard untuk membantu admin dan brand user menganalisis data: - -### Untuk Admin - -- 📊 Analisis statistik keseluruhan platform -- 🔍 Identifikasi tren dan pola penggunaan -- ⚠️ Deteksi anomali dan potensi fraud -- 📈 Rekomendasi optimasi bisnis - -### Untuk Brand User - -- 📦 Analisis performa produk dan tag -- 🗺️ Insight distribusi geografis -- 👥 Pemahaman perilaku konsumen -- 🚨 Alert untuk aktivitas mencurigakan - -### Contoh Pertanyaan ke AI Agent - -``` -"Berapa total tag yang sudah di-claim bulan ini?" -"Produk mana yang paling banyak di-scan?" -"Apakah ada pola scan yang mencurigakan?" -"Bagaimana distribusi geografis produk saya?" -"Rekomendasikan strategi untuk meningkatkan engagement" -``` - -## Cara Penggunaan +| Category | Technology | +| ---------- | ------------------------------------------------- | +| Framework | Next.js 16 (App Router), React 19, TypeScript | +| Database | MySQL + Prisma ORM | +| Auth | NextAuth v5 | +| Blockchain | ethers.js, ERC721 NFT (Base Sepolia) | +| Storage | Cloudflare R2 | +| AI | Kolosal AI (Fraud Detection), Gemini AI (NFT Art) | +| Styling | Tailwind CSS v4, shadcn/ui | +| Testing | Vitest | -### 1. Instalasi +## Features -```bash -# Clone repository -git clone https://github.com/cds-id/etags.git -cd etags +### Core -# Install dependencies -npm install +- **Brand/Product/Tag Management** - Full CRUD with metadata support +- **Blockchain Stamping** - Verify authenticity on-chain +- **QR Code Scanning** - Public verification endpoint +- **Tag Lifecycle** - Created → Distributed → Claimed → Transferred → Flagged → Revoked -# Copy environment file -cp .env.example .env -``` +### AI & NFT -### 2. Konfigurasi Environment +- **NFT Collectible** - Gas-free minting for first-hand owners with AI-generated artwork (Gemini) +- **AI Agent Dashboard** - Data analysis assistant for admin & brands +- **Fraud Detection** - AI-powered scan pattern analysis -Edit file `.env` dengan konfigurasi Anda: +### Support -```env -# Database MySQL -DATABASE_URL="mysql://username:password@localhost:3306/etags" - -# NextAuth (generate dengan: openssl rand -base64 32) -AUTH_SECRET="your_secret_here" -AUTH_TRUST_HOST=true # Wajib untuk production/deployment - -# Cloudflare R2 (opsional, untuk upload file) -R2_ACCOUNT_ID="your_account_id" -R2_ACCESS_KEY_ID="your_access_key" -R2_SECRET_ACCESS_KEY="your_secret_key" -R2_BUCKET="your_bucket" - -# Blockchain (opsional, untuk stamping) -BLOCKCHAIN_RPC_URL="https://rpc.example.com" -CONTRACT_ADDRESS="0x..." -CHAIN_ID="1" -ADMIN_WALLET="your_private_key" -``` +- **Web3 Support Tickets** - NFT holders can submit complaints via wallet connection +- **Auto Product Detection** - System detects owned products from NFT +- **Brand/Admin Routing** - Tickets route to brand, fallback to admin -### 3. Setup Database +## Quick Start ```bash -# Push schema ke database -npm run db:push - -# Buat akun admin -npm run db:create-admin +# Clone & install +git clone https://github.com/cds-id/etags.git && cd etags && npm install -# Atau dengan kredensial custom -npm run db:create-admin -- email@anda.com password123 "Nama Anda" -``` +# Configure +cp .env.example .env # Edit with your settings -### 4. Jalankan Aplikasi +# Database setup +npm run db:push && npm run db:create-admin -```bash -# Development +# Run npm run dev - -# Production -npm run build -npm start ``` -### 5. Akses Aplikasi - -| Halaman | URL | Keterangan | -| ------------ | --------- | ----------------------------- | -| Landing Page | `/` | Halaman utama publik | -| Login | `/login` | Halaman login admin | -| Dashboard | `/manage` | Dashboard admin (perlu login) | -| API Docs | `/docs` | Dokumentasi Swagger UI | - -### 6. Login Default - -``` -Email: admin@example.com -Password: admin123 -``` +**Default login:** `admin@example.com` / `admin123` -## Siklus Hidup Tag (Blockchain) +## Environment Variables -``` -CREATED (0) → Tag dibuat di blockchain - ↓ -DISTRIBUTED (1) → Tag didistribusikan ke produk - ↓ -CLAIMED (2) → Tag diklaim oleh end user - ↓ -TRANSFERRED (3) → Kepemilikan ditransfer - ↓ -FLAGGED (4) → Tag ditandai untuk review - ↓ -REVOKED (5) → Tag dicabut/dibatalkan -``` +```env +# Required +DATABASE_URL="mysql://user:pass@localhost:3306/etags" +AUTH_SECRET="your_secret" # openssl rand -base64 32 -## Arsitektur Teknis +# Optional - R2 Storage +R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET -### Struktur Aplikasi +# Optional - Blockchain +BLOCKCHAIN_RPC_URL, CONTRACT_ADDRESS, CHAIN_ID, ADMIN_WALLET -**Etags** dibangun menggunakan arsitektur modern dengan pemisahan yang jelas antara UI, Business Logic, dan Data Layer: +# Optional - NFT +NFT_CONTRACT_ADDRESS, NEXT_PUBLIC_NFT_CONTRACT_ADDRESS, GEMINI_API_KEY +# Optional - AI +KOLOSAL_API_KEY ``` -src/ -├── app/ # Next.js App Router (UI Layer) -│ ├── api/ # API Routes -│ ├── manage/ # Admin Dashboard -│ └── login/ # Authentication -├── lib/ # Business Logic Layer -│ ├── actions/ # Server Actions -│ ├── services/ # Service Layer -│ └── utils/ # Utility Functions -├── components/ # React Components -│ ├── ui/ # Reusable UI Components (shadcn/ui) -│ └── shared/ # Shared Components -└── types/ # TypeScript Type Definitions -``` - -### Tech Stack Details - -- **Frontend**: Next.js 16 with React 19, TypeScript, Tailwind CSS v4 -- **Backend**: Next.js API Routes, Server Actions -- **Database**: MySQL with Prisma ORM (Type-safe queries) -- **Auth**: NextAuth v5 (Credentials Provider) -- **Blockchain**: ethers.js untuk interaksi dengan smart contract -- **Storage**: Cloudflare R2 (S3-compatible) -- **Testing**: Vitest + React Testing Library -- **CI/CD**: GitHub Actions -- **DevOps**: Docker (Multi-stage build untuk production) -### Data Flow +## Routes -1. **User Interaction** → React Components -2. **Server Actions** → Business Logic di `lib/actions/` -3. **Service Layer** → Database via Prisma atau Blockchain via ethers.js -4. **Response** → UI Update +| Route | Description | +| ---------------- | -------------------- | +| `/` | Landing page | +| `/login` | Authentication | +| `/scan` | QR scanner | +| `/verify/[code]` | Tag verification | +| `/support` | Web3 support tickets | +| `/explorer` | Blockchain explorer | +| `/manage/*` | Admin dashboard | +| `/docs` | Swagger API docs | -### Security Features - -- ✅ Password hashing dengan bcryptjs -- ✅ Session-based authentication (NextAuth) -- ✅ Environment variables untuk credentials -- ✅ No hardcoded API keys -- ✅ Type-safe database queries (Prisma) +## Scripts -## Docker Deployment +| Command | Description | +| ---------------------------- | ------------------- | +| `npm run dev` | Development server | +| `npm run build` | Production build | +| `npm run test` | Run tests | +| `npm run test -- --coverage` | Tests with coverage | +| `npm run lint` | ESLint | +| `npm run typecheck` | TypeScript check | +| `npm run db:push` | Push schema | +| `npm run db:studio` | Prisma Studio | +| `npm run db:create-admin` | Create admin user | -### Build dan Run dengan Docker +## Docker ```bash -# Build image docker build -t etags . - -# Run container -docker run -p 3000:3000 -e DATABASE_URL="your_db_url" -e AUTH_SECRET="your_secret" etags +docker run -p 3000:3000 -e DATABASE_URL="..." -e AUTH_SECRET="..." etags ``` -## Testing - -### App Tests (Vitest) +## Architecture -```bash -# Run tests in watch mode -npm run test - -# Run tests once (CI mode) -npm run test -- --run - -# Run tests dengan coverage -npm run test -- --coverage ``` - -### Smart Contract Tests (Hardhat) - -```bash -cd smartcontracts - -# Install dependencies -npm install - -# Run tests -npm run test - -# Run with gas reporting -npm run test:gas - -# Run with coverage -npm run test:coverage +src/ +├── app/ # Next.js App Router +│ ├── api/ # API Routes +│ ├── manage/ # Admin Dashboard +│ └── support/ # Web3 Support +├── lib/ +│ ├── actions/ # Server Actions +│ └── *.ts # Utilities (db, auth, r2, blockchain) +├── components/ +│ ├── ui/ # shadcn/ui components +│ └── landing/ # Landing page components +└── tests/ # Test setup & mocks ``` -📖 Lihat [smartcontracts/README.md](./smartcontracts/README.md) untuk dokumentasi lengkap smart contract. - -### Test Coverage +## NFT Collectible Flow -Unit tests tersedia untuk semua server actions di `src/lib/actions/`: - -| File | Coverage | -| ------------- | -------- | -| dashboard.ts | 100% | -| auth.ts | ~95% | -| onboarding.ts | ~82% | -| products.ts | ~81% | -| users.ts | ~80% | -| profile.ts | ~79% | -| tags.ts | ~76% | -| brands.ts | ~74% | -| my-brand.ts | ~74% | - -## Scripts +``` +1. Scan QR → 2. Claim first-hand → 3. Connect wallet → 4. AI generates art → 5. Mint NFT → 6. Transfer to user +``` -| Command | Keterangan | -| ------------------------- | --------------------------- | -| `npm run dev` | Jalankan development server | -| `npm run build` | Build untuk production | -| `npm run test` | Run unit tests | -| `npm run lint` | Jalankan ESLint | -| `npm run typecheck` | Cek TypeScript types | -| `npm run format` | Format kode dengan Prettier | -| `npm run db:push` | Push schema ke database | -| `npm run db:studio` | Buka Prisma Studio GUI | -| `npm run db:create-admin` | Buat akun admin | +**Smart Contract:** `ETagCollectible` (ERC721) - One NFT per tag, gas-free minting ## Roadmap -### ✅ MVP (Current) - -- Manajemen Brand, Produk, dan Tag -- Blockchain Stamping untuk verifikasi keaslian -- QR Code scanning dan verifikasi -- AI Agent Dashboard untuk analisis data (Admin & Brand) -- AI-powered fraud detection -- Tag lifecycle tracking (Created → Distributed → Claimed → Transferred) - -### 🚀 Phase 2: Wallet Authentication - -- **Wallet Login untuk Brand** - Brand user dapat login menggunakan crypto wallet (MetaMask, WalletConnect) -- **Multi-signature Stamping** - Tag stamping memerlukan approval dari platform dan brand user -- Hybrid authentication (wallet + credentials) - -### 📦 Phase 3: Distribution Tracking - -- **Post-sales Tracking** - Brand dapat melacak distribusi produk setelah penjualan -- Real-time supply chain visibility -- Geolocation tracking untuk pergerakan produk -- Analytics dashboard untuk distribusi - -### 🛡️ Phase 4: Blockchain Warranty - -- **Claim-based Warranty** - User harus claim produk sebelum mendapatkan garansi -- **On-chain Warranty** - Data garansi terintegrasi dengan blockchain -- Warranty transfer saat produk dijual kembali -- Automated warranty validation +| Phase | Status | Features | +| ------- | ------ | ---------------------------------------------------------------------- | +| MVP | ✅ | Brand/Product/Tag, Blockchain Stamping, NFT, AI Agent, Fraud Detection | +| Phase 2 | 🔜 | Wallet Authentication, Multi-sig Stamping | +| Phase 3 | 🔜 | Distribution Tracking, Supply Chain | +| Phase 4 | 🔜 | Blockchain Warranty | +| Phase 5 | ✅ | Web3 Support Tickets | -📖 Lihat [ROADMAP.md](./ROADMAP.md) untuk detail lengkap. +See [ROADMAP.md](./ROADMAP.md) for details. -## Lisensi +## License -MIT License +MIT diff --git a/ROADMAP.md b/ROADMAP.md index 123ded1..344678f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,10 +21,32 @@ Fitur dasar untuk penandaan produk dan verifikasi blockchain. - [x] Tag Lifecycle - Status tracking (Created → Distributed → Claimed → Transferred → Revoked) - [x] AI Agent Dashboard - Asisten AI untuk analisis data (tersedia untuk Admin & Brand) - [x] AI Fraud Detection - Deteksi fraud menggunakan Kolosal AI +- [x] NFT Collectible - NFT untuk first-hand owner dengan AI-generated artwork (Gemini) - [x] Role-based Access - Admin dan Brand user roles - [x] File Storage - Upload ke Cloudflare R2 - [x] API Documentation - Swagger UI +### NFT Collectible Flow + +First-hand owner dapat mint NFT sebagai bukti kepemilikan digital: + +``` +1. User scan QR code tag → Verifikasi produk +2. User claim sebagai first-hand owner +3. Connect wallet (MetaMask/Web3) +4. AI generate artwork unik (Gemini) +5. NFT di-mint ke blockchain (gas-free) +6. NFT dikirim ke wallet user +``` + +**Fitur NFT:** + +- AI-Generated Artwork - Setiap NFT memiliki artwork unik dari Gemini AI +- Gas-Free Minting - User tidak bayar gas fee, platform yang menanggung +- On-Chain Proof - NFT disimpan di Base Sepolia sebagai bukti kepemilikan +- One-Per-Tag - Setiap tag hanya bisa mint satu NFT (enforced by smart contract) +- Admin Monitoring - Dashboard untuk monitoring semua NFT yang di-mint + ### AI Agent Capabilities AI Agent terintegrasi di dashboard untuk membantu pengguna menganalisis data: @@ -49,8 +71,10 @@ AI Agent terintegrasi di dashboard untuk membantu pengguna menganalisis data: - MySQL + Prisma ORM - NextAuth v5 (Credentials) - ethers.js + Base Sepolia +- ERC721 NFT (ETagCollectible contract) - Cloudflare R2 - Kolosal AI (Analytics & Fraud Detection) +- Gemini AI (NFT Art Generation) --- @@ -172,15 +196,92 @@ Sistem garansi yang terintegrasi dengan blockchain - user harus claim produk unt --- +## ✅ Phase 5: Web3 Support Ticket + +**Status: Complete** + +Sistem support ticket berbasis Web3 untuk pemilik NFT - memungkinkan first-hand owner untuk mengajukan komplain langsung ke brand. + +### Features + +#### 5.1 Web3 Authentication untuk Support + +- [x] Auto-login dengan wallet (MetaMask/WalletConnect) +- [x] Deteksi otomatis NFT yang dimiliki user dari wallet address +- [x] Verifikasi kepemilikan dari database +- [x] Wallet connection untuk session + +#### 5.2 Product Selection & Ticket Creation + +- [x] Tampilkan daftar produk yang dimiliki (dari NFT ownership) +- [x] User pilih produk yang ingin dikomplain +- [x] Form komplain dengan kategori (Defect, Missing Parts, Quality Issue, dll) +- [x] Rich text description untuk detail masalah + +#### 5.3 Ticket Routing + +- [x] Ticket otomatis dikirim ke brand dashboard +- [x] Jika brand tidak punya user aktif → fallback ke admin platform +- [x] Assignment system untuk brand team + +#### 5.4 Brand Dashboard - Ticket Management + +- [x] List semua ticket untuk brand +- [x] Filter by status (Open, In Progress, Resolved, Closed) +- [x] Reply system +- [x] Ticket status updates + +#### 5.5 Customer Support Portal + +- [x] Halaman `/support` untuk NFT holders +- [x] Track ticket status real-time +- [x] Conversation history dengan brand + +#### 5.6 Admin Platform Oversight + +- [x] Admin bisa lihat semua tickets across brands +- [x] Take over ticket jika brand tidak responsif + +### User Flow + +``` +1. User buka /support +2. Connect wallet (MetaMask/Web3) +3. System detect NFTs owned by wallet +4. User pilih produk yang bermasalah +5. Isi form komplain + upload bukti +6. Submit ticket → dikirim ke brand +7. Brand reply di dashboard +8. User dapat notification & bisa reply +9. Ticket resolved +``` + +### Fallback Flow (No Brand User) + +``` +1. Ticket masuk ke brand +2. Tidak ada brand user aktif +3. System assign ke admin platform +4. Admin handle atau assign ke brand +``` + +### Database Schema + +- **SupportTicket** - Ticket dengan status, priority, brand_id, tag_id +- **TicketMessage** - Conversation thread (user & brand replies) +- **TicketAttachment** - File attachments (images, videos) + +--- + ## 🔮 Future Considerations -Fitur yang mungkin dikembangkan setelah Phase 4: +Fitur yang mungkin dikembangkan setelah Phase 5: -### NFT Integration +### NFT Marketplace Integration -- Tag sebagai NFT untuk collectibles - Secondary market untuk produk limited edition - Royalty tracking untuk resale +- NFT trading/transfer antar user ### Cross-chain Support @@ -204,6 +305,7 @@ Fitur yang mungkin dikembangkan setelah Phase 4: | Phase 2 | Wallet Authentication | Planned | | Phase 3 | Distribution Tracking | Planned | | Phase 4 | Blockchain Warranty | Planned | +| Phase 5 | Web3 Support Ticket | ✅ Done | --- diff --git a/docs/TAG_STATUS_ARCHITECTURE.md b/docs/TAG_STATUS_ARCHITECTURE.md deleted file mode 100644 index c13db2b..0000000 --- a/docs/TAG_STATUS_ARCHITECTURE.md +++ /dev/null @@ -1,250 +0,0 @@ -# Tag Status Analysis: Database vs Blockchain - -## Current Architecture Review - -After reviewing the [CONTRACT_USAGE.md](file:///home/nst/WebstormProjects/etags/smartcontracts/CONTRACT_USAGE.md) and Prisma schema, there's an **important distinction** between database status and blockchain status that needs clarification. - ---- - -## Two Different "Status" Fields - -### 1. **Database Status** (Prisma Schema) - -Located in the `tags` table: - -```prisma -status Int @default(0) @db.TinyInt // 0 = draft, 1 = published -``` - -**Purpose**: Internal application state before blockchain stamping - -- `0` = **DRAFT** - Tag created in database but not yet stamped to blockchain -- `1` = **PUBLISHED** - Tag is stamped and ready for use - -**Requirement**: Must have `is_stamped = 1` before status can be set to `1` - -### 2. **Blockchain Status** (Smart Contract) - -Located in the `ETagRegistry` contract: - -| Value | Status | Description | -| ----- | ----------- | ------------------------- | -| 0 | CREATED | Just created | -| 1 | DISTRIBUTED | Sent to retail | -| 2 | CLAIMED | First owner claimed | -| 3 | TRANSFERRED | Ownership transferred | -| 4 | FLAGGED | Flagged for investigation | -| 5 | REVOKED | Revoked (counterfeit) | - -**Purpose**: Track tag lifecycle on-chain for transparency and immutability - ---- - -## Recommended Architecture - -### Schema Update - -Your Prisma schema should have **both** statuses: - -```diff -model Tag { - id Int @id @default(autoincrement()) - code String @unique @db.VarChar(100) - product_ids Json - metadata Json - is_stamped Int @default(0) @db.TinyInt - hash_tx String? @db.VarChar(255) -- status Int @default(0) @db.TinyInt // 0 = draft, 1 = published -+ publish_status Int @default(0) @db.TinyInt // 0 = draft, 1 = published (internal) -+ chain_status Int? @db.TinyInt // Blockchain status (0-5), nullable if not yet stamped - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@map("tags") -} -``` - -### Workflow Integration - -#### **Step 1: Create Draft Tag** (Database Only) - -```typescript -const tag = await prisma.tag.create({ - data: { - code: 'TAG-001', - product_ids: [1, 2, 3], - metadata: { - /* ... */ - }, - publish_status: 0, // draft - is_stamped: 0, - chain_status: null, // not on chain yet - }, -}); -``` - -#### **Step 2: Stamp to Blockchain** - -```typescript -// 1. Create tag on blockchain -const tagId = ethers.encodeBytes32String('TAG-001'); -const hash = ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(metadata))); -const metadataURI = 'ipfs://QmYourMetadataHash'; - -const tx = await registry.createTag(tagId, hash, metadataURI); -await tx.wait(); - -// 2. Update database -await prisma.tag.update({ - where: { code: 'TAG-001' }, - data: { - is_stamped: 1, - hash_tx: tx.hash, - chain_status: 0, // CREATED status on chain - }, -}); -``` - -#### **Step 3: Publish Tag** (Make visible to public) - -```typescript -await prisma.tag.update({ - where: { code: 'TAG-001' }, - data: { - publish_status: 1, // now published - }, -}); -``` - -#### **Step 4: Update Lifecycle Status** (Distributed, Claimed, etc.) - -```typescript -// Update on blockchain -await registry.updateStatus(tagId, 1); // DISTRIBUTED - -// Sync to database -await prisma.tag.update({ - where: { code: 'TAG-001' }, - data: { - chain_status: 1, // DISTRIBUTED - }, -}); -``` - ---- - -## Why Both Status Fields? - -### `publish_status` (Database) - -- **Controls visibility** in your application -- Allows drafts to be created and reviewed before blockchain stamping -- Once published, users can scan and validate the tag -- **Does NOT need** to be on blockchain - -### `chain_status` (Blockchain) - -- **Tracks lifecycle** for transparency and auditability -- Immutable record of tag journey (created → distributed → claimed) -- Can be validated by anyone with blockchain access -- **Must be** synchronized with blockchain state - ---- - -## Implementation Recommendations - -### 1. Add `chain_status` to Prisma Schema - -```prisma -model Tag { - // ... existing fields - publish_status Int @default(0) @db.TinyInt // 0 = draft, 1 = published - chain_status Int? @db.TinyInt // Blockchain status (0-5), null if not stamped - // ... - - @@map("tags") -} -``` - -### 2. Create Status Sync Service - -```typescript -// src/lib/blockchain/tag-sync.ts -import { prisma } from '@/lib/db'; -import { ethers } from 'ethers'; -import abi from '@/smartcontracts/ETagRegistry.abi.json'; - -const CONTRACT_ADDRESS = '0x51162BEA5FB292CBabF2715e0686bF6165baaEC1'; -const provider = new ethers.JsonRpcProvider('https://sepolia.base.org'); -const registry = new ethers.Contract(CONTRACT_ADDRESS, abi, provider); - -export async function syncTagStatus(tagCode: string) { - const tag = await prisma.tag.findUnique({ where: { code: tagCode } }); - - if (!tag || !tag.is_stamped) { - throw new Error('Tag not stamped to blockchain'); - } - - const tagId = ethers.encodeBytes32String(tagCode); - const result = await registry.validateTag(tagId); - - // Update database with blockchain status - await prisma.tag.update({ - where: { code: tagCode }, - data: { - chain_status: Number(result.status), - }, - }); - - return result; -} -``` - -### 3. Status Update Flow - -```typescript -// Update status on blockchain AND database -export async function updateTagChainStatus( - tagCode: string, - newStatus: number, // 0-5 - wallet: ethers.Wallet -) { - const tagId = ethers.encodeBytes32String(tagCode); - const registryWithSigner = registry.connect(wallet); - - // Update on blockchain - const tx = await registryWithSigner.updateStatus(tagId, newStatus); - await tx.wait(); - - // Sync to database - await prisma.tag.update({ - where: { code: tagCode }, - data: { - chain_status: newStatus, - }, - }); -} -``` - ---- - -## Summary - -> [!IMPORTANT] -> Your intuition is **correct** - tag status should be stored on-chain after `is_stamped = true`. However, you should maintain **TWO separate status fields**: - -1. **`publish_status`** (database only) - Controls application visibility (draft/published) -2. **`chain_status`** (synced with blockchain) - Tracks lifecycle (created/distributed/claimed/etc.) - -This separation provides: - -- ✅ Draft capability before blockchain stamping -- ✅ Complete lifecycle tracking on-chain -- ✅ Easy synchronization between database and blockchain -- ✅ Single source of truth for tag authenticity (blockchain) - -The blockchain already has the `updateStatus()` function ready - you just need to: - -1. Add `chain_status` field to your database schema -2. Create sync service to keep database in sync with blockchain -3. Use `publish_status` for internal draft/publish workflow diff --git a/lighthouserc.json b/lighthouserc.json new file mode 100644 index 0000000..c3208f8 --- /dev/null +++ b/lighthouserc.json @@ -0,0 +1,30 @@ +{ + "ci": { + "collect": { + "startServerCommand": "npm run start", + "startServerReadyPattern": "Ready", + "url": ["http://localhost:3000/"], + "numberOfRuns": 3, + "settings": { + "preset": "desktop", + "onlyCategories": [ + "performance", + "accessibility", + "best-practices", + "seo" + ] + } + }, + "assert": { + "assertions": { + "categories:performance": ["warn", { "minScore": 0.7 }], + "categories:accessibility": ["error", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.8 }], + "categories:seo": ["warn", { "minScore": 0.8 }] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} diff --git a/next.config.ts b/next.config.ts index 46484bb..7f742f4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -13,6 +13,16 @@ const nextConfig: NextConfig = { protocol: 'https', hostname: 'picsum.photos', }, + { + // Cloudflare R2 public domain for NFT images + protocol: 'https', + hostname: '*.r2.dev', + }, + { + // Alternative R2 custom domain pattern + protocol: 'https', + hostname: '*.cloudflarestorage.com', + }, ], }, }; diff --git a/package-lock.json b/package-lock.json index 9820494..f87b5d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@aws-sdk/client-s3": "^3.705.0", "@aws-sdk/s3-request-presigner": "^3.705.0", "@fingerprintjs/fingerprintjs": "^5.0.1", + "@google/genai": "^1.30.0", + "@gsap/react": "^2.1.2", "@prisma/client": "^6.1.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -21,13 +23,18 @@ "@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", + "framer-motion": "^12.23.25", + "gsap": "^3.13.0", "html5-qrcode": "^2.3.8", "lucide-react": "^0.555.0", - "next": "16.0.6", + "mapbox-gl": "^3.16.0", + "mime": "^4.1.0", + "next": "16.0.7", "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "qrcode": "^1.5.4", @@ -47,6 +54,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/bcryptjs": "^2.4.6", + "@types/mime": "^3.0.4", "@types/node": "^20", "@types/qrcode": "^1.5.6", "@types/react": "^19", @@ -1291,7 +1299,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 +1309,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 +1446,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", @@ -2404,6 +2412,58 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@google/genai": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", + "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.20.1" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@google/genai/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@gsap/react": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@gsap/react/-/react-2.1.2.tgz", + "integrity": "sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==", + "license": "SEE LICENSE AT https://gsap.com/standard-license", + "peerDependencies": { + "gsap": "^3.12.5", + "react": ">=17" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2921,6 +2981,96 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2977,6 +3127,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", @@ -2991,9 +3193,9 @@ } }, "node_modules/@next/env": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.6.tgz", - "integrity": "sha512-PFTK/G/vM3UJwK5XDYMFOqt8QW42mmhSgdKDapOlCqBUAOfJN2dyOnASR/xUR/JRrro0pLohh/zOJ77xUQWQAg==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", + "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -3007,9 +3209,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.6.tgz", - "integrity": "sha512-AGzKiPlDiui+9JcPRHLI4V9WFTTcKukhJTfK9qu3e0tz+Y/88B7vo5yZoO7UaikplJEHORzG3QaBFQfkjhnL0Q==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", + "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", "cpu": [ "arm64" ], @@ -3023,9 +3225,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.6.tgz", - "integrity": "sha512-LlLLNrK9WCIUkq2GciWDcquXYIf7vLxX8XE49gz7EncssZGL1vlHwgmURiJsUZAvk0HM1a8qb1ABDezsjAE/jw==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", + "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", "cpu": [ "x64" ], @@ -3039,9 +3241,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.6.tgz", - "integrity": "sha512-r04NzmLSGGfG8EPXKVK72N5zDNnq9pa9el78LhdtqIC3zqKh74QfKHnk24DoK4PEs6eY7sIK/CnNpt30oc59kg==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", + "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", "cpu": [ "arm64" ], @@ -3055,9 +3257,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.6.tgz", - "integrity": "sha512-hfB/QV0hA7lbD1OJxp52wVDlpffUMfyxUB5ysZbb/pBC5iuhyLcEKSVQo56PFUUmUQzbMsAtUu6k2Gh9bBtWXA==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", + "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", "cpu": [ "arm64" ], @@ -3071,9 +3273,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.6.tgz", - "integrity": "sha512-PZJushBgfvKhJBy01yXMdgL+l5XKr7uSn5jhOQXQXiH3iPT2M9iG64yHpPNGIKitKrHJInwmhPVGogZBAJOCPw==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", + "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", "cpu": [ "x64" ], @@ -3087,9 +3289,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.6.tgz", - "integrity": "sha512-LqY76IojrH9yS5fyATjLzlOIOgwyzBuNRqXwVxcGfZ58DWNQSyfnLGlfF6shAEqjwlDNLh4Z+P0rnOI87Y9jEw==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", + "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", "cpu": [ "x64" ], @@ -3103,9 +3305,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.6.tgz", - "integrity": "sha512-eIfSNNqAkj0tqKRf0u7BVjqylJCuabSrxnpSENY3YKApqwDMeAqYPmnOwmVe6DDl3Lvkbe7cJAyP6i9hQ5PmmQ==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", + "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", "cpu": [ "arm64" ], @@ -3119,9 +3321,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.6.tgz", - "integrity": "sha512-QGs18P4OKdK9y2F3Th42+KGnwsc2iaThOe6jxQgP62kslUU4W+g6AzI6bdIn/pslhSfxjAMU5SjakfT5Fyo/xA==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", + "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", "cpu": [ "x64" ], @@ -3215,6 +3417,16 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@prisma/client": { "version": "6.19.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.0.tgz", @@ -3241,7 +3453,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 +3466,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 +3487,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 +3506,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 +6093,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": { @@ -6696,6 +6908,66 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.6.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", @@ -6938,6 +7210,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 +7247,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", @@ -6969,6 +7271,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mime": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-3.0.4.tgz", + "integrity": "sha512-iJt33IQnVRkqeqC7PzBHPTC6fDlRNRW8vjrgqtScAhrmMwe8c4Eo7+fUGTa+XdWrpEgpyKWMYmi2dIwMAYRzPw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -6985,6 +7294,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 +7329,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 +7339,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 +7355,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", @@ -7829,7 +8154,6 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -8207,7 +8531,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" @@ -8278,6 +8602,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -8378,11 +8711,17 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/c12": { "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 +8932,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 +8958,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 +9248,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" @@ -9001,7 +9346,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -9032,6 +9376,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 +9414,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": { @@ -9172,10 +9523,19 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/data-urls": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", - "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", "dev": true, "license": "MIT", "dependencies": { @@ -9322,7 +9682,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 +9740,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 +9765,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 +9855,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 +9887,32 @@ "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "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", @@ -9549,14 +9930,13 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "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, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -10395,7 +10775,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 +10803,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", @@ -10558,6 +10938,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -10717,6 +11120,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -10741,6 +11172,45 @@ "node": ">=0.4.x" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/framer-motion": { + "version": "12.23.25", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.25.tgz", + "integrity": "sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", @@ -10818,6 +11288,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -10838,6 +11337,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 +11446,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 +11460,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", @@ -11089,6 +11600,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -11115,6 +11653,31 @@ "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/gsap": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz", + "integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -11374,7 +11937,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -12119,7 +12681,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -12194,11 +12755,26 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "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" @@ -12312,6 +12888,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -12383,6 +12968,33 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "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 +13809,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 +13838,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 +13887,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", @@ -14142,6 +14796,21 @@ "node": ">=8.6" } }, + "node_modules/mime": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", + "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", + "funding": [ + "https://github.com/sponsors/broofa" + ], + "license": "MIT", + "bin": { + "mime": "bin/cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -14220,12 +14889,42 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "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", @@ -14297,12 +14996,12 @@ } }, "node_modules/next": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/next/-/next-16.0.6.tgz", - "integrity": "sha512-2zOZ/4FdaAp5hfCU/RnzARlZzBsjaTZ/XjNQmuyYLluAPM7kcrbIkdeO2SL0Ysd1vnrSgU+GwugfeWX1cUCgCg==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", + "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", "license": "MIT", "dependencies": { - "@next/env": "16.0.6", + "@next/env": "16.0.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -14315,14 +15014,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.0.6", - "@next/swc-darwin-x64": "16.0.6", - "@next/swc-linux-arm64-gnu": "16.0.6", - "@next/swc-linux-arm64-musl": "16.0.6", - "@next/swc-linux-x64-gnu": "16.0.6", - "@next/swc-linux-x64-musl": "16.0.6", - "@next/swc-win32-arm64-msvc": "16.0.6", - "@next/swc-win32-x64-msvc": "16.0.6", + "@next/swc-darwin-arm64": "16.0.7", + "@next/swc-darwin-x64": "16.0.7", + "@next/swc-linux-arm64-gnu": "16.0.7", + "@next/swc-linux-arm64-musl": "16.0.7", + "@next/swc-linux-x64-gnu": "16.0.7", + "@next/swc-linux-x64-musl": "16.0.7", + "@next/swc-win32-arm64-msvc": "16.0.7", + "@next/swc-win32-x64-msvc": "16.0.7", "sharp": "^0.34.4" }, "peerDependencies": { @@ -14449,6 +15148,24 @@ "node": ">=10.5.0" } }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-fetch-commonjs": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch-commonjs/-/node-fetch-commonjs-3.3.2.tgz", @@ -14470,7 +15187,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 +15213,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 +15375,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 +15427,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", @@ -14828,6 +15538,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14931,7 +15647,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -14944,18 +15659,52 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "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, + "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 +15743,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 +15812,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 +15902,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 +15963,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 +15989,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 +16046,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 +16104,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 +16329,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 +16606,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 +16656,71 @@ "dev": true, "license": "MIT" }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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 +16945,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", @@ -16238,7 +17088,6 @@ "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, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -16251,7 +17100,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16432,6 +17280,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", @@ -16507,6 +17361,27 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -16652,6 +17527,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -16728,6 +17616,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 +17912,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 +17966,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 +18329,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", @@ -18562,7 +19465,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -18713,6 +19615,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 1458238..aa0d39c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "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", + "generate:icons": "bash scripts/generate-icons.sh" }, "config": { "commitizen": { @@ -31,6 +33,7 @@ "@aws-sdk/client-s3": "^3.705.0", "@aws-sdk/s3-request-presigner": "^3.705.0", "@fingerprintjs/fingerprintjs": "^5.0.1", + "@google/genai": "^1.30.0", "@prisma/client": "^6.1.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -41,13 +44,17 @@ "@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", + "framer-motion": "^12.23.25", "html5-qrcode": "^2.3.8", "lucide-react": "^0.555.0", - "next": "16.0.6", + "mapbox-gl": "^3.16.0", + "mime": "^4.1.0", + "next": "16.0.7", "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "qrcode": "^1.5.4", @@ -67,6 +74,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", "@types/bcryptjs": "^2.4.6", + "@types/mime": "^3.0.4", "@types/node": "^20", "@types/qrcode": "^1.5.6", "@types/react": "^19", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35026e5..183fe18 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,7 +23,9 @@ model User { created_at DateTime @default(now()) updated_at DateTime @updatedAt - brand Brand? @relation(fields: [brand_id], references: [id], onDelete: SetNull) + brand Brand? @relation(fields: [brand_id], references: [id], onDelete: SetNull) + assignedTickets SupportTicket[] @relation("TicketAssignee") + ticketMessages TicketMessage[] @relation("TicketMessageSender") @@index([brand_id]) @@index([created_at]) @@ -41,6 +43,7 @@ model Brand { products Product[] users User[] + tickets SupportTicket[] @@index([created_at]) @@map("brands") @@ -74,7 +77,9 @@ model Tag { created_at DateTime @default(now()) updated_at DateTime @updatedAt - scans TagScan[] + scans TagScan[] + nft TagNFT? + tickets SupportTicket[] @@index([created_at]) @@map("tags") @@ -101,3 +106,84 @@ model TagScan { @@index([fingerprint_id]) @@map("tag_scans") } + +model TagNFT { + id Int @id @default(autoincrement()) + tag_id Int @unique + token_id String @db.VarChar(100) // On-chain token ID + owner_address String @db.VarChar(42) // Wallet address (0x + 40 hex chars) + image_url String @db.VarChar(500) // R2 URL for NFT image + metadata_url String @db.VarChar(500) // R2 URL for metadata JSON + mint_tx_hash String? @db.VarChar(66) // Mint transaction hash + transfer_tx_hash String? @db.VarChar(66) // Transfer transaction hash (if different from mint) + created_at DateTime @default(now()) + + tag Tag @relation(fields: [tag_id], references: [id], onDelete: Cascade) + + @@index([owner_address]) + @@index([created_at]) + @@map("tag_nfts") +} + +// ============ Support Ticket System ============ + +model SupportTicket { + id Int @id @default(autoincrement()) + ticket_number String @unique @db.VarChar(20) // e.g. TKT-20251204-ABCD + tag_id Int + brand_id Int + wallet_address String @db.VarChar(42) // NFT owner's wallet + category String @db.VarChar(50) // defect, quality, missing_parts, warranty, other + subject String @db.VarChar(200) + description String @db.Text + status String @default("open") @db.VarChar(20) // open, in_progress, resolved, closed + priority String @default("normal") @db.VarChar(20) // low, normal, high, urgent + assigned_to Int? // User ID (brand user or admin) + resolved_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + tag Tag @relation(fields: [tag_id], references: [id], onDelete: Cascade) + brand Brand @relation(fields: [brand_id], references: [id], onDelete: Cascade) + assignee User? @relation("TicketAssignee", fields: [assigned_to], references: [id], onDelete: SetNull) + messages TicketMessage[] + attachments TicketAttachment[] + + @@index([brand_id]) + @@index([wallet_address]) + @@index([status]) + @@index([created_at]) + @@map("support_tickets") +} + +model TicketMessage { + id Int @id @default(autoincrement()) + ticket_id Int + sender_type String @db.VarChar(20) // customer, brand, admin + sender_address String? @db.VarChar(42) // wallet address if customer + sender_user_id Int? // user_id if brand/admin + message String @db.Text + created_at DateTime @default(now()) + + ticket SupportTicket @relation(fields: [ticket_id], references: [id], onDelete: Cascade) + sender User? @relation("TicketMessageSender", fields: [sender_user_id], references: [id], onDelete: SetNull) + + @@index([ticket_id]) + @@index([created_at]) + @@map("ticket_messages") +} + +model TicketAttachment { + id Int @id @default(autoincrement()) + ticket_id Int + file_url String @db.VarChar(500) + file_name String @db.VarChar(200) + file_type String @db.VarChar(50) // image/png, image/jpeg, etc. + file_size Int // in bytes + created_at DateTime @default(now()) + + ticket SupportTicket @relation(fields: [ticket_id], references: [id], onDelete: Cascade) + + @@index([ticket_id]) + @@map("ticket_attachments") +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..6113b6e Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon-16x16.png b/public/favicon-16x16.png new file mode 100644 index 0000000..86f92b7 Binary files /dev/null and b/public/favicon-16x16.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png new file mode 100644 index 0000000..546ef61 Binary files /dev/null and b/public/favicon-32x32.png differ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..76fa4b3 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/feature-analytics.png b/public/feature-analytics.png new file mode 100644 index 0000000..89f5736 Binary files /dev/null and b/public/feature-analytics.png differ diff --git a/public/feature-nft.png b/public/feature-nft.png new file mode 100644 index 0000000..40824b2 Binary files /dev/null and b/public/feature-nft.png differ diff --git a/public/feature-scanning.png b/public/feature-scanning.png new file mode 100644 index 0000000..50528d5 Binary files /dev/null and b/public/feature-scanning.png differ diff --git a/public/feature-security.png b/public/feature-security.png new file mode 100644 index 0000000..39e4243 Binary files /dev/null and b/public/feature-security.png differ diff --git a/public/feature-support.png b/public/feature-support.png new file mode 100644 index 0000000..c047f72 Binary files /dev/null and b/public/feature-support.png differ diff --git a/public/hero-illustration.png b/public/hero-illustration.png new file mode 100644 index 0000000..233b666 Binary files /dev/null and b/public/hero-illustration.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..83b9c51 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..e410588 Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/illustrations.png b/public/illustrations.png new file mode 100644 index 0000000..faabd9d Binary files /dev/null and b/public/illustrations.png differ diff --git a/public/industry-electronics.png b/public/industry-electronics.png new file mode 100644 index 0000000..2a30d97 Binary files /dev/null and b/public/industry-electronics.png differ diff --git a/public/industry-fashion.png b/public/industry-fashion.png new file mode 100644 index 0000000..e298932 Binary files /dev/null and b/public/industry-fashion.png differ diff --git a/public/industry-pharma.png b/public/industry-pharma.png new file mode 100644 index 0000000..e2881b1 Binary files /dev/null and b/public/industry-pharma.png differ diff --git a/public/logo-icon.png b/public/logo-icon.png new file mode 100644 index 0000000..85aeb1c Binary files /dev/null and b/public/logo-icon.png differ diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..0bea0dd Binary files /dev/null and b/public/logo.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..fc7838b --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,64 @@ +{ + "name": "Etags - Platform Penandaan Produk & Stamping Blockchain", + "short_name": "Etags", + "description": "Aplikasi untuk mengelola brand, produk, dan tag dengan pelacakan transaksi blockchain untuk tujuan autentikasi dan verifikasi keaslian produk.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#0c0a09", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["business", "productivity", "utilities"], + "shortcuts": [ + { + "name": "Scanner", + "short_name": "Scan", + "description": "Scan QR codes for product verification", + "url": "/scan", + "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Dashboard", + "short_name": "Manage", + "description": "Manage brands, products, and tags", + "url": "/manage", + "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] + }, + { + "name": "Explorer", + "short_name": "Explorer", + "description": "Explore blockchain tags", + "url": "/explorer", + "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] + } + ] +} diff --git a/scripts/generate-icons.sh b/scripts/generate-icons.sh new file mode 100755 index 0000000..cfb1bce --- /dev/null +++ b/scripts/generate-icons.sh @@ -0,0 +1,91 @@ +#!/bin/bash + +# Generate PWA icons from logo.png +# This script uses ImageMagick to create all required icon sizes + +set -e # Exit on error + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${BLUE}========================================${NC}" +echo -e "${BLUE} PWA Icon Generator for Etags${NC}" +echo -e "${BLUE}========================================${NC}" +echo "" + +# Check if ImageMagick is installed +if ! command -v convert &> /dev/null; then + echo -e "${YELLOW}⚠️ ImageMagick is not installed!${NC}" + echo "Please install it first:" + echo " - Ubuntu/Debian: sudo apt-get install imagemagick" + echo " - macOS: brew install imagemagick" + echo " - Fedora: sudo dnf install imagemagick" + exit 1 +fi + +# Check if logo.png exists +if [ ! -f "public/logo.png" ]; then + echo -e "${YELLOW}⚠️ public/logo.png not found!${NC}" + echo "Please ensure logo.png exists in the public directory." + exit 1 +fi + +echo -e "${GREEN}✓${NC} ImageMagick found" +echo -e "${GREEN}✓${NC} Logo source: public/logo.png" +echo "" + +# Get logo dimensions +LOGO_INFO=$(file public/logo.png) +echo "Logo info: $LOGO_INFO" +echo "" + +echo "Generating icons..." +echo "" + +# Generate favicon sizes +echo -e "${BLUE}→${NC} Generating favicon-16x16.png (16x16)" +convert public/logo.png -resize 16x16 public/favicon-16x16.png +echo -e "${GREEN}✓${NC} public/favicon-16x16.png" + +echo -e "${BLUE}→${NC} Generating favicon-32x32.png (32x32)" +convert public/logo.png -resize 32x32 public/favicon-32x32.png +echo -e "${GREEN}✓${NC} public/favicon-32x32.png" + +# Generate multi-size favicon.ico +echo -e "${BLUE}→${NC} Generating favicon.ico (multi-size: 16, 32, 48)" +convert public/logo.png -define icon:auto-resize=16,32,48 public/favicon.ico +echo -e "${GREEN}✓${NC} public/favicon.ico" + +# Generate Apple touch icon +echo -e "${BLUE}→${NC} Generating apple-touch-icon.png (180x180)" +convert public/logo.png -resize 180x180 public/apple-touch-icon.png +echo -e "${GREEN}✓${NC} public/apple-touch-icon.png" + +# Generate PWA icons +echo -e "${BLUE}→${NC} Generating icon-192.png (192x192)" +convert public/logo.png -resize 192x192 public/icon-192.png +echo -e "${GREEN}✓${NC} public/icon-192.png" + +echo -e "${BLUE}→${NC} Generating icon-512.png (512x512)" +convert public/logo.png -resize 512x512 public/icon-512.png +echo -e "${GREEN}✓${NC} public/icon-512.png" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} ✓ All icons generated successfully!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo "Generated files:" +echo " • favicon-16x16.png (16x16)" +echo " • favicon-32x32.png (32x32)" +echo " • favicon.ico (multi-size)" +echo " • apple-touch-icon.png (180x180)" +echo " • icon-192.png (192x192)" +echo " • icon-512.png (512x512)" +echo "" +echo -e "${BLUE}ℹ${NC} To regenerate icons after updating logo.png, run:" +echo " npm run generate:icons" +echo "" 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(); + }); diff --git a/scripts/test-gemini-image.ts b/scripts/test-gemini-image.ts new file mode 100644 index 0000000..05bac7b --- /dev/null +++ b/scripts/test-gemini-image.ts @@ -0,0 +1,164 @@ +/** + * Test script for Gemini image generation + * Run with: npx tsx scripts/test-gemini-image.ts + */ + +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as path from 'path'; +import { GoogleGenAI } from '@google/genai'; +import mime from 'mime'; + +// Load environment variables +dotenv.config(); + +interface ProductInfo { + name: string; + brand: string; + description?: string; +} + +async function generateNFTImage(tagCode: string, productInfo: ProductInfo) { + const apiKey = process.env.GEMINI_API_KEY; + + if (!apiKey) { + throw new Error('GEMINI_API_KEY environment variable is not set'); + } + + const ai = new GoogleGenAI({ apiKey }); + + const prompt = `Create a unique digital collectible artwork for an authentic product ownership certificate. + +Product Details: +- Product Name: ${productInfo.name} +- Brand: ${productInfo.brand} +${productInfo.description ? `- Description: ${productInfo.description}` : ''} +- Tag Code: ${tagCode} + +Art Requirements: +1. Style: Modern, premium, certificate-like digital art +2. Theme: Authenticity, ownership, blockchain verification +3. Elements to include: + - Abstract representation of the product category + - Brand identity elements (colors, patterns) + - Subtle authenticity seal or badge aesthetic + - The tag code "${tagCode}" subtly incorporated + - Holographic or iridescent effects suggesting security +4. Color palette: Premium, sophisticated colors that convey trust and authenticity +5. Format: Square aspect ratio (1:1), suitable for NFT display +6. Do NOT include any text except the tag code +7. Make it visually striking and collectible + +Generate a high-quality, unique artwork that celebrates authentic product ownership.`; + + console.log('Sending request to Gemini API...'); + console.log('Prompt:', prompt.substring(0, 200) + '...\n'); + + const config = { + responseModalities: ['IMAGE', 'TEXT'] as ('IMAGE' | 'TEXT')[], + imageConfig: { + imageSize: '1K' as const, + }, + }; + + const contents = [ + { + role: 'user' as const, + parts: [{ text: prompt }], + }, + ]; + + const response = await ai.models.generateContentStream({ + model: 'gemini-2.0-flash-exp-image-generation', + config, + contents, + }); + + let imageBuffer: Buffer | null = null; + let mimeType: string | null = null; + + for await (const chunk of response) { + if ( + !chunk.candidates || + !chunk.candidates[0].content || + !chunk.candidates[0].content.parts + ) { + continue; + } + + const parts = chunk.candidates[0].content.parts; + for (const part of parts) { + if ('inlineData' in part && part.inlineData) { + const inlineData = part.inlineData; + mimeType = inlineData.mimeType || 'image/png'; + imageBuffer = Buffer.from(inlineData.data || '', 'base64'); + const fileExtension = mime.getExtension(mimeType) || 'png'; + console.log( + `Found image! MIME: ${mimeType}, Extension: ${fileExtension}, Size: ${imageBuffer.length} bytes` + ); + } else if ('text' in part && part.text) { + console.log('Text response:', part.text.substring(0, 200)); + } + } + } + + return { imageBuffer, mimeType }; +} + +async function main() { + console.log('=== Gemini Image Generation Test (SDK) ===\n'); + + // Check if API key is set + if (!process.env.GEMINI_API_KEY) { + console.error('ERROR: GEMINI_API_KEY is not set in .env file'); + process.exit(1); + } + + console.log( + 'API Key found:', + process.env.GEMINI_API_KEY.substring(0, 10) + '...\n' + ); + + // Test data + const tagCode = 'ETAG-TEST-001'; + const productInfo: ProductInfo = { + name: 'Premium Leather Wallet', + brand: 'Luxury Brand', + description: + 'Handcrafted genuine leather bifold wallet with RFID protection', + }; + + console.log('Test Parameters:'); + console.log('- Tag Code:', tagCode); + console.log('- Product:', productInfo.name); + console.log('- Brand:', productInfo.brand); + console.log(''); + + try { + const { imageBuffer, mimeType } = await generateNFTImage( + tagCode, + productInfo + ); + + if (imageBuffer) { + const extension = mime.getExtension(mimeType || 'image/png') || 'png'; + const outputPath = path.join( + process.cwd(), + `test-nft-output.${extension}` + ); + fs.writeFileSync(outputPath, imageBuffer); + console.log('\n=== SUCCESS ==='); + console.log('Image saved to:', outputPath); + console.log('File size:', imageBuffer.length, 'bytes'); + console.log('MIME type:', mimeType); + } else { + console.log('\n=== FAILED ==='); + console.log('No image data received from Gemini API'); + } + } catch (error) { + console.error('\n=== ERROR ==='); + console.error(error); + } +} + +main().catch(console.error); diff --git a/scripts/test-nft-full-flow.ts b/scripts/test-nft-full-flow.ts new file mode 100644 index 0000000..cd56b84 --- /dev/null +++ b/scripts/test-nft-full-flow.ts @@ -0,0 +1,423 @@ +/** + * Full NFT Claim Flow Test + * Tests the complete flow: Gemini art generation -> R2 upload -> blockchain mint + * + * Run with: npx tsx scripts/test-nft-full-flow.ts [owner_address] + * + * Required env vars: + * - GEMINI_API_KEY + * - R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_DOMAIN + * - BLOCKCHAIN_RPC_URL, NFT_CONTRACT_ADDRESS, ADMIN_WALLET + */ + +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ethers } from 'ethers'; +import { GoogleGenAI } from '@google/genai'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +// Load environment variables +dotenv.config(); + +// ============================================================================= +// Configuration +// ============================================================================= + +const config = { + gemini: { + apiKey: process.env.GEMINI_API_KEY || '', + }, + r2: { + accountId: process.env.R2_ACCOUNT_ID || '', + accessKeyId: process.env.R2_ACCESS_KEY_ID || '', + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || '', + bucket: process.env.R2_BUCKET || '', + publicDomain: process.env.R2_PUBLIC_DOMAIN || '', + }, + blockchain: { + rpcUrl: process.env.BLOCKCHAIN_RPC_URL || 'https://sepolia.base.org', + contractAddress: process.env.NFT_CONTRACT_ADDRESS || '', + adminWallet: process.env.ADMIN_WALLET || '', + }, +}; + +// NFT Contract ABI +const NFT_ABI = [ + 'function mintTo(address to, string calldata tagCode, string calldata tokenURI) external returns (uint256)', + 'function isTagMinted(string calldata tagCode) external view returns (bool)', + 'function getTokenByTag(string calldata tagCode) external view returns (uint256)', + 'function ownerOf(uint256 tokenId) external view returns (address)', + 'function tokenURI(uint256 tokenId) external view returns (string)', + 'function totalSupply() external view returns (uint256)', + 'event CollectibleMinted(uint256 indexed tokenId, bytes32 indexed tagCodeHash, string tagCode, address indexed owner, string tokenURI)', +]; + +// ============================================================================= +// Types +// ============================================================================= + +interface ProductInfo { + name: string; + brand: string; + description?: string; +} + +interface NFTMetadata { + name: string; + description: string; + image: string; + external_url: string; + attributes: Array<{ + trait_type: string; + value: string; + }>; +} + +// ============================================================================= +// Step 1: Generate Art with Gemini +// ============================================================================= + +async function generateNFTArt( + tagCode: string, + productInfo: ProductInfo +): Promise { + console.log('\n📸 Step 1: Generating NFT art with Gemini...'); + + if (!config.gemini.apiKey) { + throw new Error('GEMINI_API_KEY is not set'); + } + + const ai = new GoogleGenAI({ apiKey: config.gemini.apiKey }); + + const prompt = `Create a unique digital collectible artwork for an authentic product ownership certificate. + +Product Details: +- Product Name: ${productInfo.name} +- Brand: ${productInfo.brand} +${productInfo.description ? `- Description: ${productInfo.description}` : ''} +- Tag Code: ${tagCode} + +Art Requirements: +1. Style: Modern, premium, certificate-like digital art +2. Theme: Authenticity, ownership, blockchain verification +3. Elements to include: + - Abstract representation of the product category + - Brand identity elements (colors, patterns) + - Subtle authenticity seal or badge aesthetic + - The tag code "${tagCode}" subtly incorporated + - Holographic or iridescent effects suggesting security +4. Color palette: Premium, sophisticated colors that convey trust and authenticity +5. Format: Square aspect ratio (1:1), suitable for NFT display +6. Do NOT include any text except the tag code +7. Make it visually striking and collectible + +Generate a high-quality, unique artwork that celebrates authentic product ownership.`; + + console.log(' Sending request to Gemini API...'); + + const response = await ai.models.generateContentStream({ + model: 'gemini-2.0-flash-exp-image-generation', + config: { + responseModalities: ['IMAGE', 'TEXT'], + imageConfig: { imageSize: '1K' }, + }, + contents: [{ role: 'user', parts: [{ text: prompt }] }], + }); + + let imageBuffer: Buffer | null = null; + + for await (const chunk of response) { + if (!chunk.candidates?.[0]?.content?.parts) continue; + + for (const part of chunk.candidates[0].content.parts) { + if ('inlineData' in part && part.inlineData?.data) { + imageBuffer = Buffer.from(part.inlineData.data, 'base64'); + console.log(` ✅ Image generated: ${imageBuffer.length} bytes`); + } + } + } + + if (!imageBuffer) { + throw new Error('No image data received from Gemini'); + } + + // Save locally for verification + const localPath = path.join(process.cwd(), `test-nft-${tagCode}.png`); + fs.writeFileSync(localPath, imageBuffer); + console.log(` 💾 Saved locally: ${localPath}`); + + return imageBuffer; +} + +// ============================================================================= +// Step 2: Upload to R2 +// ============================================================================= + +async function uploadToR2( + tagCode: string, + imageBuffer: Buffer, + metadata: NFTMetadata +): Promise<{ imageUrl: string; metadataUrl: string }> { + console.log('\n☁️ Step 2: Uploading to R2...'); + + if ( + !config.r2.accountId || + !config.r2.accessKeyId || + !config.r2.secretAccessKey + ) { + throw new Error('R2 credentials are not set'); + } + + const r2Client = new S3Client({ + region: 'auto', + endpoint: `https://${config.r2.accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.r2.accessKeyId, + secretAccessKey: config.r2.secretAccessKey, + }, + }); + + const getUrl = (key: string) => { + if (config.r2.publicDomain) { + return `${config.r2.publicDomain}/${key}`; + } + return `https://${config.r2.accountId}.r2.cloudflarestorage.com/${config.r2.bucket}/${key}`; + }; + + // Upload image + const imageKey = `nfts/${tagCode}/image.png`; + console.log(` Uploading image: ${imageKey}`); + + await r2Client.send( + new PutObjectCommand({ + Bucket: config.r2.bucket, + Key: imageKey, + Body: imageBuffer, + ContentType: 'image/png', + }) + ); + + const imageUrl = getUrl(imageKey); + console.log(` ✅ Image uploaded: ${imageUrl}`); + + // Update metadata with image URL + metadata.image = imageUrl; + + // Upload metadata + const metadataKey = `nfts/${tagCode}/metadata.json`; + console.log(` Uploading metadata: ${metadataKey}`); + + await r2Client.send( + new PutObjectCommand({ + Bucket: config.r2.bucket, + Key: metadataKey, + Body: JSON.stringify(metadata, null, 2), + ContentType: 'application/json', + }) + ); + + const metadataUrl = getUrl(metadataKey); + console.log(` ✅ Metadata uploaded: ${metadataUrl}`); + + return { imageUrl, metadataUrl }; +} + +// ============================================================================= +// Step 3: Mint NFT on Blockchain +// ============================================================================= + +async function mintNFT( + tagCode: string, + ownerAddress: string, + metadataUrl: string +): Promise<{ tokenId: string; txHash: string }> { + console.log('\n⛓️ Step 3: Minting NFT on blockchain...'); + + if (!config.blockchain.contractAddress || !config.blockchain.adminWallet) { + throw new Error('Blockchain config is not set'); + } + + const provider = new ethers.JsonRpcProvider(config.blockchain.rpcUrl); + const wallet = new ethers.Wallet(config.blockchain.adminWallet, provider); + const contract = new ethers.Contract( + config.blockchain.contractAddress, + NFT_ABI, + wallet + ); + + console.log(` Contract: ${config.blockchain.contractAddress}`); + console.log(` Minter: ${wallet.address}`); + console.log(` Owner: ${ownerAddress}`); + console.log(` Metadata: ${metadataUrl}`); + + // Check if already minted + const isMinted = await contract.isTagMinted(tagCode); + if (isMinted) { + throw new Error(`Tag ${tagCode} already has an NFT minted`); + } + + // Mint + console.log(' Sending transaction...'); + const tx = await contract.mintTo(ownerAddress, tagCode, metadataUrl); + console.log(` Tx hash: ${tx.hash}`); + + console.log(' Waiting for confirmation...'); + const receipt = await tx.wait(); + console.log(` ✅ Confirmed in block: ${receipt.blockNumber}`); + + // Get token ID from event + let tokenId = 'unknown'; + for (const log of receipt.logs) { + try { + const parsed = contract.interface.parseLog({ + topics: log.topics as string[], + data: log.data, + }); + if (parsed?.name === 'CollectibleMinted') { + tokenId = parsed.args.tokenId.toString(); + break; + } + } catch { + // Not our event + } + } + + console.log(` Token ID: ${tokenId}`); + + return { tokenId, txHash: tx.hash }; +} + +// ============================================================================= +// Main Flow +// ============================================================================= + +async function main() { + console.log('╔════════════════════════════════════════════════════════════╗'); + console.log('║ NFT FULL FLOW TEST (Gemini + R2 + Mint) ║'); + console.log('╚════════════════════════════════════════════════════════════╝'); + + // Validate config + const missingConfig: string[] = []; + if (!config.gemini.apiKey) missingConfig.push('GEMINI_API_KEY'); + if (!config.r2.accountId) missingConfig.push('R2_ACCOUNT_ID'); + if (!config.r2.accessKeyId) missingConfig.push('R2_ACCESS_KEY_ID'); + if (!config.r2.secretAccessKey) missingConfig.push('R2_SECRET_ACCESS_KEY'); + if (!config.r2.bucket) missingConfig.push('R2_BUCKET'); + if (!config.blockchain.contractAddress) + missingConfig.push('NFT_CONTRACT_ADDRESS'); + if (!config.blockchain.adminWallet) missingConfig.push('ADMIN_WALLET'); + + if (missingConfig.length > 0) { + console.error( + '\n❌ Missing environment variables:', + missingConfig.join(', ') + ); + process.exit(1); + } + + // Test data + const tagCode = `ETAG-FULL-${Date.now()}`; + const ownerAddress = + process.argv[2] || new ethers.Wallet(config.blockchain.adminWallet).address; + const productInfo: ProductInfo = { + name: 'Premium Leather Wallet', + brand: 'Luxury Brand Co.', + description: + 'Handcrafted genuine leather bifold wallet with RFID protection', + }; + + console.log('\n📋 Test Configuration:'); + console.log(` Tag Code: ${tagCode}`); + console.log(` Owner: ${ownerAddress}`); + console.log(` Product: ${productInfo.name}`); + console.log(` Brand: ${productInfo.brand}`); + + try { + // Step 1: Generate art + const imageBuffer = await generateNFTArt(tagCode, productInfo); + + // Build initial metadata + const metadata: NFTMetadata = { + name: `Etags Collectible - ${productInfo.name}`, + description: `Authentic ownership certificate for ${productInfo.name} by ${productInfo.brand}. First-hand claim verified on ${new Date().toISOString().split('T')[0]}. This NFT proves authentic product ownership recorded on the blockchain.`, + image: '', // Will be updated after upload + external_url: `https://etags.example.com/verify/${tagCode}`, + attributes: [ + { trait_type: 'Brand', value: productInfo.brand }, + { trait_type: 'Product', value: productInfo.name }, + { trait_type: 'Tag Code', value: tagCode }, + { + trait_type: 'Claim Date', + value: new Date().toISOString().split('T')[0], + }, + { trait_type: 'Ownership Type', value: 'First Hand' }, + { trait_type: 'Verification', value: 'Blockchain Verified' }, + ], + }; + + // Step 2: Upload to R2 + const { imageUrl, metadataUrl } = await uploadToR2( + tagCode, + imageBuffer, + metadata + ); + + // Step 3: Mint NFT + const { tokenId, txHash } = await mintNFT( + tagCode, + ownerAddress, + metadataUrl + ); + + // Update metadata with token ID + metadata.name = `Etags Collectible #${tokenId} - ${productInfo.name}`; + console.log('\n📝 Updating metadata with token ID...'); + + const r2Client = new S3Client({ + region: 'auto', + endpoint: `https://${config.r2.accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.r2.accessKeyId, + secretAccessKey: config.r2.secretAccessKey, + }, + }); + + await r2Client.send( + new PutObjectCommand({ + Bucket: config.r2.bucket, + Key: `nfts/${tagCode}/metadata.json`, + Body: JSON.stringify(metadata, null, 2), + ContentType: 'application/json', + }) + ); + console.log(' ✅ Metadata updated'); + + // Summary + console.log( + '\n╔════════════════════════════════════════════════════════════╗' + ); + console.log( + '║ 🎉 SUCCESS! 🎉 ║' + ); + console.log( + '╚════════════════════════════════════════════════════════════╝' + ); + console.log('\n📊 NFT Details:'); + console.log(` Token ID: ${tokenId}`); + console.log(` Owner: ${ownerAddress}`); + console.log(` Tag Code: ${tagCode}`); + console.log(` Image URL: ${imageUrl}`); + console.log(` Metadata URL: ${metadataUrl}`); + console.log(` Tx Hash: ${txHash}`); + console.log(`\n🔗 View on BaseScan:`); + console.log(` https://sepolia.basescan.org/tx/${txHash}`); + console.log(`\n🖼️ View NFT image:`); + console.log(` ${imageUrl}`); + } catch (error) { + console.error('\n❌ Test failed:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/smartcontracts/AUDIT SCAN.pdf b/smartcontracts/AUDIT SCAN.pdf new file mode 100644 index 0000000..9cd833f Binary files /dev/null and b/smartcontracts/AUDIT SCAN.pdf differ diff --git a/smartcontracts/ETagRegistry.abi.json b/smartcontracts/ETagRegistry.abi.json deleted file mode 100644 index 0f25287..0000000 --- a/smartcontracts/ETagRegistry.abi.json +++ /dev/null @@ -1,456 +0,0 @@ -[ - { "inputs": [], "stateMutability": "nonpayable", "type": "constructor" }, - { - "inputs": [ - { "internalType": "bytes32", "name": "hash", "type": "bytes32" } - ], - "name": "HashAlreadyRegistered", - "type": "error" - }, - { "inputs": [], "name": "InvalidMetadataURI", "type": "error" }, - { - "inputs": [ - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" } - ], - "name": "TagAlreadyExists", - "type": "error" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" } - ], - "name": "TagNotFound", - "type": "error" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "Paused", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "role", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "bytes32", - "name": "previousAdminRole", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "bytes32", - "name": "newAdminRole", - "type": "bytes32" - } - ], - "name": "RoleAdminChanged", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "role", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "account", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "sender", - "type": "address" - } - ], - "name": "RoleGranted", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "role", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "address", - "name": "account", - "type": "address" - }, - { - "indexed": true, - "internalType": "address", - "name": "sender", - "type": "address" - } - ], - "name": "RoleRevoked", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "tagId", - "type": "bytes32" - }, - { - "indexed": true, - "internalType": "bytes32", - "name": "hash", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "string", - "name": "metadataURI", - "type": "string" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "timestamp", - "type": "uint256" - } - ], - "name": "TagCreated", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "tagId", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "string", - "name": "reason", - "type": "string" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "timestamp", - "type": "uint256" - } - ], - "name": "TagRevoked", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": true, - "internalType": "bytes32", - "name": "tagId", - "type": "bytes32" - }, - { - "indexed": false, - "internalType": "enum ETagRegistry.TagStatus", - "name": "oldStatus", - "type": "uint8" - }, - { - "indexed": false, - "internalType": "enum ETagRegistry.TagStatus", - "name": "newStatus", - "type": "uint8" - }, - { - "indexed": false, - "internalType": "uint256", - "name": "timestamp", - "type": "uint256" - } - ], - "name": "TagStatusChanged", - "type": "event" - }, - { - "anonymous": false, - "inputs": [ - { - "indexed": false, - "internalType": "address", - "name": "account", - "type": "address" - } - ], - "name": "Unpaused", - "type": "event" - }, - { - "inputs": [], - "name": "ADMIN_ROLE", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "DEFAULT_ADMIN_ROLE", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "OPERATOR_ROLE", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" }, - { "internalType": "bytes32", "name": "hash", "type": "bytes32" }, - { "internalType": "string", "name": "metadataURI", "type": "string" } - ], - "name": "createTag", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32[]", "name": "tagIds", "type": "bytes32[]" }, - { "internalType": "bytes32[]", "name": "hashes", "type": "bytes32[]" }, - { "internalType": "string[]", "name": "metadataURIs", "type": "string[]" } - ], - "name": "createTagBatch", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "role", "type": "bytes32" } - ], - "name": "getRoleAdmin", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "address", "name": "account", "type": "address" } - ], - "name": "grantOperator", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "role", "type": "bytes32" }, - { "internalType": "address", "name": "account", "type": "address" } - ], - "name": "grantRole", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "role", "type": "bytes32" }, - { "internalType": "address", "name": "account", "type": "address" } - ], - "name": "hasRole", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "name": "hashToTagId", - "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "pause", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [], - "name": "paused", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "role", "type": "bytes32" }, - { "internalType": "address", "name": "account", "type": "address" } - ], - "name": "renounceRole", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "address", "name": "account", "type": "address" } - ], - "name": "revokeOperator", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "role", "type": "bytes32" }, - { "internalType": "address", "name": "account", "type": "address" } - ], - "name": "revokeRole", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" }, - { "internalType": "string", "name": "reason", "type": "string" } - ], - "name": "revokeTag", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes4", "name": "interfaceId", "type": "bytes4" } - ], - "name": "supportsInterface", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "hash", "type": "bytes32" } - ], - "name": "tagExistsByHash", - "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], - "name": "tags", - "outputs": [ - { "internalType": "bytes32", "name": "hash", "type": "bytes32" }, - { "internalType": "address", "name": "creator", "type": "address" }, - { "internalType": "uint256", "name": "createdAt", "type": "uint256" }, - { "internalType": "string", "name": "metadataURI", "type": "string" }, - { - "internalType": "enum ETagRegistry.TagStatus", - "name": "status", - "type": "uint8" - }, - { "internalType": "bool", "name": "exists", "type": "bool" } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "totalTags", - "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [], - "name": "unpause", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" }, - { - "internalType": "enum ETagRegistry.TagStatus", - "name": "newStatus", - "type": "uint8" - } - ], - "name": "updateStatus", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "hash", "type": "bytes32" } - ], - "name": "validateByHash", - "outputs": [ - { "internalType": "bool", "name": "isValid", "type": "bool" }, - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" }, - { "internalType": "string", "name": "metadataURI", "type": "string" }, - { - "internalType": "enum ETagRegistry.TagStatus", - "name": "status", - "type": "uint8" - }, - { "internalType": "uint256", "name": "createdAt", "type": "uint256" } - ], - "stateMutability": "view", - "type": "function" - }, - { - "inputs": [ - { "internalType": "bytes32", "name": "tagId", "type": "bytes32" } - ], - "name": "validateTag", - "outputs": [ - { "internalType": "bool", "name": "isValid", "type": "bool" }, - { "internalType": "bytes32", "name": "hash", "type": "bytes32" }, - { "internalType": "string", "name": "metadataURI", "type": "string" }, - { - "internalType": "enum ETagRegistry.TagStatus", - "name": "status", - "type": "uint8" - }, - { "internalType": "uint256", "name": "createdAt", "type": "uint256" } - ], - "stateMutability": "view", - "type": "function" - } -] diff --git a/smartcontracts/contracts/ETagCollectible.sol b/smartcontracts/contracts/ETagCollectible.sol new file mode 100644 index 0000000..886f416 --- /dev/null +++ b/smartcontracts/contracts/ETagCollectible.sol @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/security/Pausable.sol"; + +/** + * @title ETagCollectible + * @dev ERC721 NFT for first-hand tag claimers + * @notice Minted when users claim tags as first-hand owners on Web3 browsers + */ +contract ETagCollectible is ERC721, ERC721URIStorage, AccessControl, Pausable { + // ============ ROLES ============ + bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + // ============ STATE ============ + uint256 private _nextTokenId; + + // Mapping from tag code hash to token ID (one NFT per tag) + mapping(bytes32 => uint256) public tagToToken; + + // Mapping from token ID to tag code hash (reverse lookup) + mapping(uint256 => bytes32) public tokenToTag; + + // Track if a tag has been minted + mapping(bytes32 => bool) public tagMinted; + + // ============ EVENTS ============ + event CollectibleMinted( + uint256 indexed tokenId, + bytes32 indexed tagCodeHash, + string tagCode, + address indexed owner, + string tokenURI + ); + + // ============ ERRORS ============ + error TagAlreadyMinted(string tagCode); + error InvalidAddress(); + error InvalidTagCode(); + error InvalidTokenURI(); + error TokenNotFound(uint256 tokenId); + + // ============ CONSTRUCTOR ============ + constructor() ERC721("Etags Collectible", "ETAGC") { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(ADMIN_ROLE, msg.sender); + _grantRole(MINTER_ROLE, msg.sender); + + // Start token IDs at 1 + _nextTokenId = 1; + } + + // ============ MINTING ============ + + /** + * @dev Mint a new collectible NFT for a first-hand tag claimer + * @param to Address to mint the NFT to + * @param tagCode The tag code string (e.g., "TAG-ABC123") + * @param uri The token URI pointing to metadata JSON on R2 + * @return tokenId The ID of the minted token + */ + function mintTo( + address to, + string calldata tagCode, + string calldata uri + ) external onlyRole(MINTER_ROLE) whenNotPaused returns (uint256) { + if (to == address(0)) revert InvalidAddress(); + if (bytes(tagCode).length == 0) revert InvalidTagCode(); + if (bytes(uri).length == 0) revert InvalidTokenURI(); + + bytes32 tagCodeHash = keccak256(abi.encodePacked(tagCode)); + + if (tagMinted[tagCodeHash]) revert TagAlreadyMinted(tagCode); + + uint256 tokenId = _nextTokenId++; + + // Record mappings + tagToToken[tagCodeHash] = tokenId; + tokenToTag[tokenId] = tagCodeHash; + tagMinted[tagCodeHash] = true; + + // Mint the token + _safeMint(to, tokenId); + _setTokenURI(tokenId, uri); + + emit CollectibleMinted(tokenId, tagCodeHash, tagCode, to, uri); + + return tokenId; + } + + // ============ VIEWS ============ + + /** + * @dev Get token ID for a tag code + * @param tagCode The tag code string + * @return tokenId The token ID (0 if not minted) + */ + function getTokenByTag(string calldata tagCode) external view returns (uint256) { + bytes32 tagCodeHash = keccak256(abi.encodePacked(tagCode)); + return tagToToken[tagCodeHash]; + } + + /** + * @dev Check if a tag has been minted + * @param tagCode The tag code string + * @return bool True if the tag has an NFT + */ + function isTagMinted(string calldata tagCode) external view returns (bool) { + bytes32 tagCodeHash = keccak256(abi.encodePacked(tagCode)); + return tagMinted[tagCodeHash]; + } + + /** + * @dev Get the tag code hash for a token + * @param tokenId The token ID + * @return bytes32 The tag code hash + */ + function getTagByToken(uint256 tokenId) external view returns (bytes32) { + if (tokenId == 0 || tokenId >= _nextTokenId) revert TokenNotFound(tokenId); + return tokenToTag[tokenId]; + } + + /** + * @dev Get the total number of minted tokens + * @return uint256 Total supply + */ + function totalSupply() external view returns (uint256) { + return _nextTokenId - 1; + } + + // ============ ADMIN ============ + + /** + * @dev Pause minting + */ + function pause() external onlyRole(ADMIN_ROLE) { + _pause(); + } + + /** + * @dev Unpause minting + */ + function unpause() external onlyRole(ADMIN_ROLE) { + _unpause(); + } + + /** + * @dev Grant minter role to an address + * @param account Address to grant role to + */ + function grantMinter(address account) external onlyRole(ADMIN_ROLE) { + _grantRole(MINTER_ROLE, account); + } + + /** + * @dev Revoke minter role from an address + * @param account Address to revoke role from + */ + function revokeMinter(address account) external onlyRole(ADMIN_ROLE) { + _revokeRole(MINTER_ROLE, account); + } + + // ============ OVERRIDES ============ + + function tokenURI(uint256 tokenId) + public + view + override(ERC721, ERC721URIStorage) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721, ERC721URIStorage, AccessControl) + returns (bool) + { + return super.supportsInterface(interfaceId); + } + + function _burn(uint256 tokenId) + internal + override(ERC721, ERC721URIStorage) + { + super._burn(tokenId); + } +} diff --git a/smartcontracts/hardhat.config.ts b/smartcontracts/hardhat.config.ts index a27620e..138abdb 100644 --- a/smartcontracts/hardhat.config.ts +++ b/smartcontracts/hardhat.config.ts @@ -44,6 +44,9 @@ const config: HardhatUserConfig = { currency: 'USD', coinmarketcap: process.env.COINMARKETCAP_API_KEY, }, + sourcify: { + enabled: true, + }, etherscan: { apiKey: { baseSepolia: BASESCAN_API_KEY, diff --git a/smartcontracts/scripts/deploy-nft.ts b/smartcontracts/scripts/deploy-nft.ts new file mode 100644 index 0000000..d6f7d20 --- /dev/null +++ b/smartcontracts/scripts/deploy-nft.ts @@ -0,0 +1,129 @@ +import { ethers } from 'hardhat'; +import * as dotenv from 'dotenv'; + +// Load environment variables from parent directory +dotenv.config({ path: '../.env' }); + +async function main() { + console.log('Deploying ETagCollectible...\n'); + + const [deployer] = await ethers.getSigners(); + console.log('Deployer account:', deployer.address); + + const balance = await ethers.provider.getBalance(deployer.address); + console.log('Deployer balance:', ethers.formatEther(balance), 'ETH'); + + // Check for custom owner address from .env + const ownerAddress = process.env.CONTRACT_OWNER || deployer.address; + const isCustomOwner = + ownerAddress.toLowerCase() !== deployer.address.toLowerCase(); + + if (isCustomOwner) { + console.log('\nCustom owner specified:', ownerAddress); + console.log('Roles will be transferred after deployment.'); + } + + // Deploy contract + console.log('\nDeploying contract...'); + const ETagCollectible = await ethers.getContractFactory('ETagCollectible'); + const collectible = await ETagCollectible.deploy(); + + await collectible.waitForDeployment(); + + const contractAddress = await collectible.getAddress(); + console.log('ETagCollectible deployed to:', contractAddress); + + // Wait for a few block confirmations before interacting + console.log('\nWaiting for block confirmations...'); + await collectible.deploymentTransaction()?.wait(3); + + // Role constants + const DEFAULT_ADMIN_ROLE = ethers.ZeroHash; + const ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('ADMIN_ROLE')); + const MINTER_ROLE = ethers.keccak256(ethers.toUtf8Bytes('MINTER_ROLE')); + + // If custom owner, transfer all roles + if (isCustomOwner) { + console.log('\nTransferring roles to owner...'); + + // Grant all roles to owner + console.log('- Granting DEFAULT_ADMIN_ROLE to owner...'); + await collectible.grantRole(DEFAULT_ADMIN_ROLE, ownerAddress); + + console.log('- Granting ADMIN_ROLE to owner...'); + await collectible.grantRole(ADMIN_ROLE, ownerAddress); + + console.log('- Granting MINTER_ROLE to owner...'); + await collectible.grantRole(MINTER_ROLE, ownerAddress); + + // Renounce deployer roles (owner should do this after verifying) + console.log( + '\nNote: Deployer still has roles. Owner should revoke deployer roles after verification.' + ); + } + + // Verify roles + console.log('\nRole verification:'); + console.log( + '- Deployer has DEFAULT_ADMIN_ROLE:', + await collectible.hasRole(DEFAULT_ADMIN_ROLE, deployer.address) + ); + console.log( + '- Deployer has ADMIN_ROLE:', + await collectible.hasRole(ADMIN_ROLE, deployer.address) + ); + console.log( + '- Deployer has MINTER_ROLE:', + await collectible.hasRole(MINTER_ROLE, deployer.address) + ); + + if (isCustomOwner) { + console.log( + '- Owner has DEFAULT_ADMIN_ROLE:', + await collectible.hasRole(DEFAULT_ADMIN_ROLE, ownerAddress) + ); + console.log( + '- Owner has ADMIN_ROLE:', + await collectible.hasRole(ADMIN_ROLE, ownerAddress) + ); + console.log( + '- Owner has MINTER_ROLE:', + await collectible.hasRole(MINTER_ROLE, ownerAddress) + ); + } + + // Contract details + console.log('\nContract details:'); + console.log('- Name:', await collectible.name()); + console.log('- Symbol:', await collectible.symbol()); + console.log('- Total Supply:', (await collectible.totalSupply()).toString()); + + console.log('\n=== DEPLOYMENT COMPLETE ==='); + console.log('Contract Address:', contractAddress); + console.log('Deployer:', deployer.address); + if (isCustomOwner) { + console.log('Owner:', ownerAddress); + } + + console.log('\nAdd this to your .env file:'); + console.log(`NFT_CONTRACT_ADDRESS=${contractAddress}`); + console.log(`NEXT_PUBLIC_NFT_CONTRACT_ADDRESS=${contractAddress}`); + + console.log('\nTo verify on BaseScan, run:'); + console.log(`npx hardhat verify --network baseSepolia ${contractAddress}`); + + if (isCustomOwner) { + console.log('\n=== IMPORTANT ==='); + console.log('After verification, the owner should revoke deployer roles:'); + console.log(`1. Call revokeRole(ADMIN_ROLE, ${deployer.address})`); + console.log(`2. Call revokeRole(MINTER_ROLE, ${deployer.address})`); + console.log(`3. Call revokeRole(DEFAULT_ADMIN_ROLE, ${deployer.address})`); + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/smartcontracts/scripts/interact.ts b/smartcontracts/scripts/interact.ts index c3a6bcd..f2dfc11 100644 --- a/smartcontracts/scripts/interact.ts +++ b/smartcontracts/scripts/interact.ts @@ -91,6 +91,7 @@ async function main() { 'REVOKED', ][Number(newStatus)] ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (error.message.includes('TagAlreadyExists')) { console.log( diff --git a/smartcontracts/test/ETagCollectible.test.ts b/smartcontracts/test/ETagCollectible.test.ts new file mode 100644 index 0000000..b2bb040 --- /dev/null +++ b/smartcontracts/test/ETagCollectible.test.ts @@ -0,0 +1,276 @@ +import { expect } from 'chai'; +import { ethers } from 'hardhat'; +import type { ETagCollectible } from '../typechain-types'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; + +describe('ETagCollectible', function () { + let collectible: ETagCollectible; + let owner: SignerWithAddress; + let minter: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + + const ADMIN_ROLE = ethers.keccak256(ethers.toUtf8Bytes('ADMIN_ROLE')); + const MINTER_ROLE = ethers.keccak256(ethers.toUtf8Bytes('MINTER_ROLE')); + + beforeEach(async function () { + [owner, minter, user1, user2] = await ethers.getSigners(); + + const ETagCollectibleFactory = + await ethers.getContractFactory('ETagCollectible'); + const deployed = await ETagCollectibleFactory.deploy(); + await deployed.waitForDeployment(); + collectible = deployed as unknown as ETagCollectible; + + // Grant minter role to minter address + await collectible.grantMinter(minter.address); + }); + + describe('Deployment', function () { + it('Should set the correct name and symbol', async function () { + expect(await collectible.name()).to.equal('Etags Collectible'); + expect(await collectible.symbol()).to.equal('ETAGC'); + }); + + it('Should grant admin and minter roles to deployer', async function () { + expect(await collectible.hasRole(ADMIN_ROLE, owner.address)).to.be.true; + expect(await collectible.hasRole(MINTER_ROLE, owner.address)).to.be.true; + }); + + it('Should start with zero total supply', async function () { + expect(await collectible.totalSupply()).to.equal(0); + }); + }); + + describe('Minting', function () { + const tagCode = 'TAG-ABC123'; + const tokenURI = 'https://r2.example.com/nfts/TAG-ABC123/1/metadata.json'; + + it('Should mint NFT to user', async function () { + const tx = await collectible + .connect(minter) + .mintTo(user1.address, tagCode, tokenURI); + const receipt = await tx.wait(); + + expect(await collectible.ownerOf(1)).to.equal(user1.address); + expect(await collectible.tokenURI(1)).to.equal(tokenURI); + expect(await collectible.totalSupply()).to.equal(1); + }); + + it('Should emit CollectibleMinted event', async function () { + const tagCodeHash = ethers.keccak256(ethers.toUtf8Bytes(tagCode)); + + await expect( + collectible.connect(minter).mintTo(user1.address, tagCode, tokenURI) + ) + .to.emit(collectible, 'CollectibleMinted') + .withArgs(1, tagCodeHash, tagCode, user1.address, tokenURI); + }); + + it('Should prevent minting same tag twice', async function () { + await collectible + .connect(minter) + .mintTo(user1.address, tagCode, tokenURI); + + await expect( + collectible.connect(minter).mintTo(user2.address, tagCode, tokenURI) + ).to.be.revertedWithCustomError(collectible, 'TagAlreadyMinted'); + }); + + it('Should revert for zero address', async function () { + await expect( + collectible + .connect(minter) + .mintTo(ethers.ZeroAddress, tagCode, tokenURI) + ).to.be.revertedWithCustomError(collectible, 'InvalidAddress'); + }); + + it('Should revert for empty tag code', async function () { + await expect( + collectible.connect(minter).mintTo(user1.address, '', tokenURI) + ).to.be.revertedWithCustomError(collectible, 'InvalidTagCode'); + }); + + it('Should revert for empty token URI', async function () { + await expect( + collectible.connect(minter).mintTo(user1.address, tagCode, '') + ).to.be.revertedWithCustomError(collectible, 'InvalidTokenURI'); + }); + + it('Should reject non-minter', async function () { + await expect( + collectible.connect(user1).mintTo(user1.address, tagCode, tokenURI) + ).to.be.reverted; + }); + }); + + describe('Tag Lookup', function () { + const tagCode = 'TAG-XYZ789'; + const tokenURI = 'https://r2.example.com/nfts/TAG-XYZ789/1/metadata.json'; + + beforeEach(async function () { + await collectible + .connect(minter) + .mintTo(user1.address, tagCode, tokenURI); + }); + + it('Should return token ID for tag code', async function () { + expect(await collectible.getTokenByTag(tagCode)).to.equal(1); + }); + + it('Should return 0 for unminted tag', async function () { + expect(await collectible.getTokenByTag('TAG-UNKNOWN')).to.equal(0); + }); + + it('Should check if tag is minted', async function () { + expect(await collectible.isTagMinted(tagCode)).to.be.true; + expect(await collectible.isTagMinted('TAG-UNKNOWN')).to.be.false; + }); + + it('Should return tag hash for token ID', async function () { + const tagCodeHash = ethers.keccak256(ethers.toUtf8Bytes(tagCode)); + expect(await collectible.getTagByToken(1)).to.equal(tagCodeHash); + }); + + it('Should revert for invalid token ID', async function () { + await expect(collectible.getTagByToken(0)).to.be.revertedWithCustomError( + collectible, + 'TokenNotFound' + ); + await expect( + collectible.getTagByToken(999) + ).to.be.revertedWithCustomError(collectible, 'TokenNotFound'); + }); + }); + + describe('Access Control', function () { + it('Should allow admin to grant minter role', async function () { + await collectible.grantMinter(user1.address); + expect(await collectible.hasRole(MINTER_ROLE, user1.address)).to.be.true; + }); + + it('Should allow admin to revoke minter role', async function () { + await collectible.grantMinter(user1.address); + await collectible.revokeMinter(user1.address); + expect(await collectible.hasRole(MINTER_ROLE, user1.address)).to.be.false; + }); + + it('Should reject non-admin from granting roles', async function () { + await expect(collectible.connect(user1).grantMinter(user2.address)).to.be + .reverted; + }); + }); + + describe('Pausable', function () { + const tagCode = 'TAG-PAUSE1'; + const tokenURI = 'https://r2.example.com/nfts/TAG-PAUSE1/1/metadata.json'; + + it('Should allow admin to pause', async function () { + await collectible.pause(); + expect(await collectible.paused()).to.be.true; + }); + + it('Should allow admin to unpause', async function () { + await collectible.pause(); + await collectible.unpause(); + expect(await collectible.paused()).to.be.false; + }); + + it('Should prevent minting when paused', async function () { + await collectible.pause(); + + await expect( + collectible.connect(minter).mintTo(user1.address, tagCode, tokenURI) + ).to.be.revertedWith('Pausable: paused'); + }); + + it('Should allow minting after unpause', async function () { + await collectible.pause(); + await collectible.unpause(); + + await expect( + collectible.connect(minter).mintTo(user1.address, tagCode, tokenURI) + ).to.not.be.reverted; + }); + + it('Should reject non-admin from pausing', async function () { + await expect(collectible.connect(user1).pause()).to.be.reverted; + }); + }); + + describe('ERC721 Standard', function () { + const tagCode = 'TAG-ERC721'; + const tokenURI = 'https://r2.example.com/nfts/TAG-ERC721/1/metadata.json'; + + beforeEach(async function () { + await collectible + .connect(minter) + .mintTo(user1.address, tagCode, tokenURI); + }); + + it('Should support ERC721 interface', async function () { + // ERC721 interface ID + expect(await collectible.supportsInterface('0x80ac58cd')).to.be.true; + }); + + it('Should support ERC721Metadata interface', async function () { + // ERC721Metadata interface ID + expect(await collectible.supportsInterface('0x5b5e139f')).to.be.true; + }); + + it('Should support AccessControl interface', async function () { + // AccessControl interface ID + expect(await collectible.supportsInterface('0x7965db0b')).to.be.true; + }); + + it('Should allow transfer by owner', async function () { + await collectible + .connect(user1) + .transferFrom(user1.address, user2.address, 1); + expect(await collectible.ownerOf(1)).to.equal(user2.address); + }); + + it('Should allow approval and transferFrom', async function () { + await collectible.connect(user1).approve(user2.address, 1); + await collectible + .connect(user2) + .transferFrom(user1.address, user2.address, 1); + expect(await collectible.ownerOf(1)).to.equal(user2.address); + }); + }); + + describe('Multiple Mints', function () { + it('Should handle multiple mints correctly', async function () { + const tags = ['TAG-001', 'TAG-002', 'TAG-003']; + const baseURI = 'https://r2.example.com/nfts/'; + + for (let i = 0; i < tags.length; i++) { + await collectible + .connect(minter) + .mintTo(user1.address, tags[i], `${baseURI}${tags[i]}/metadata.json`); + } + + expect(await collectible.totalSupply()).to.equal(3); + expect(await collectible.balanceOf(user1.address)).to.equal(3); + + for (let i = 0; i < tags.length; i++) { + expect(await collectible.getTokenByTag(tags[i])).to.equal(i + 1); + expect(await collectible.isTagMinted(tags[i])).to.be.true; + } + }); + + it('Should mint to different users', async function () { + await collectible + .connect(minter) + .mintTo(user1.address, 'TAG-A', 'https://example.com/a'); + await collectible + .connect(minter) + .mintTo(user2.address, 'TAG-B', 'https://example.com/b'); + + expect(await collectible.ownerOf(1)).to.equal(user1.address); + expect(await collectible.ownerOf(2)).to.equal(user2.address); + expect(await collectible.balanceOf(user1.address)).to.equal(1); + expect(await collectible.balanceOf(user2.address)).to.equal(1); + }); + }); +}); diff --git a/src/app/api/explorer/route.ts b/src/app/api/explorer/route.ts index 2402aaf..75fbdb4 100644 --- a/src/app/api/explorer/route.ts +++ b/src/app/api/explorer/route.ts @@ -8,6 +8,20 @@ import { type DecodedTransaction, type ContractStats, } from '@/lib/explorer'; +import { prisma } from '@/lib/db'; + +export interface PublicNFT { + id: number; + tokenId: string; + ownerAddress: string; + imageUrl: string; + metadataUrl: string; + mintTxHash: string | null; + createdAt: string; + tagCode: string; + productName: string | null; + brandName: string | null; +} export type ExplorerResponse = { success: boolean; @@ -30,10 +44,15 @@ export type ExplorerResponse = { statusLabel?: string; createdAt?: string; }; + nfts?: PublicNFT[]; + nftStats?: { + totalMinted: number; + }; pagination?: { page: number; pageSize: number; hasMore: boolean; + total?: number; }; }; @@ -41,9 +60,9 @@ export type ExplorerResponse = { * GET /api/explorer * * Query params: - * - action: 'stats' | 'transactions' | 'transaction' | 'events' | 'tag' - * - page: number (for transactions) - * - pageSize: number (for transactions) + * - action: 'stats' | 'transactions' | 'transaction' | 'events' | 'tag' | 'nfts' + * - page: number (for transactions/nfts) + * - pageSize: number (for transactions/nfts) * - txHash: string (for single transaction) * - tagCode: string (for tag lookup) */ @@ -134,6 +153,91 @@ export async function GET(request: NextRequest) { }); } + case 'nfts': { + const page = parseInt(searchParams.get('page') || '1'); + const pageSize = parseInt(searchParams.get('pageSize') || '12'); + const skip = (page - 1) * pageSize; + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = prisma as any; + + const [nfts, total] = await Promise.all([ + db.tagNFT.findMany({ + skip, + take: pageSize, + orderBy: { created_at: 'desc' }, + include: { + tag: true, + }, + }), + db.tagNFT.count(), + ]); + + // Map NFTs to public format with product/brand info + const publicNfts: PublicNFT[] = await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + nfts.map(async (nft: any) => { + const productIds = (nft.tag?.product_ids as number[]) || []; + let productName: string | null = null; + let brandName: string | null = null; + + if (productIds.length > 0) { + const productData = await prisma.product.findFirst({ + where: { id: { in: productIds } }, + include: { brand: true }, + }); + + if (productData) { + const metadata = productData.metadata as { name?: string }; + productName = metadata.name || productData.code; + brandName = productData.brand.name; + } + } + + return { + id: nft.id, + tokenId: nft.token_id, + ownerAddress: nft.owner_address, + imageUrl: nft.image_url, + metadataUrl: nft.metadata_url, + mintTxHash: nft.mint_tx_hash, + createdAt: nft.created_at.toISOString(), + tagCode: nft.tag?.code || '', + productName, + brandName, + }; + }) + ); + + return NextResponse.json({ + success: true, + nfts: publicNfts, + nftStats: { totalMinted: total }, + pagination: { + page, + pageSize, + hasMore: skip + pageSize < total, + total, + }, + }); + } catch (error) { + // TagNFT table may not exist yet + console.error('Error fetching NFTs:', error); + return NextResponse.json({ + success: true, + nfts: [], + nftStats: { totalMinted: 0 }, + pagination: { + page: 1, + pageSize: 12, + hasMore: false, + total: 0, + }, + }); + } + } + default: return NextResponse.json( { success: false, error: 'Invalid action' }, diff --git a/src/app/api/scan/claim-nft/route.ts b/src/app/api/scan/claim-nft/route.ts new file mode 100644 index 0000000..7854cd9 --- /dev/null +++ b/src/app/api/scan/claim-nft/route.ts @@ -0,0 +1,282 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { processNFTClaim, isTagNFTMinted } from '@/lib/nft-collectible'; +import { + checkRateLimit, + getClientIdentifier, + getRateLimitHeaders, + RATE_LIMITS, +} from '@/lib/rate-limit'; +import { checkCSRF } from '@/lib/csrf'; +import { ethers } from 'ethers'; + +export type ClaimNFTRequest = { + tagCode: string; + fingerprintId: string; + walletAddress: string; +}; + +export type ClaimNFTResponse = { + success: boolean; + message: string; + nft?: { + tokenId: string; + imageUrl: string; + metadataUrl: string; + mintTxHash: string; + ownerAddress?: string; + }; + error?: string; +}; + +/** + * POST /api/scan/claim-nft + * + * Claim an NFT collectible for a first-hand tag claim + * Requirements: + * - Tag must exist and be stamped + * - User must have a first-hand claim on this tag + * - NFT must not already be minted for this tag + * - Valid wallet address required + */ +export async function POST(request: NextRequest) { + // Get IP address for rate limiting + const forwardedFor = request.headers.get('x-forwarded-for'); + const ipAddress = forwardedFor + ? forwardedFor.split(',')[0].trim() + : request.headers.get('x-real-ip') || '127.0.0.1'; + + // CSRF validation + const csrfCheck = await checkCSRF(request); + if (!csrfCheck.valid) { + return NextResponse.json( + { + success: false, + message: 'Akses tidak valid', + error: 'Silakan refresh halaman dan coba lagi.', + }, + { status: 403 } + ); + } + + try { + const body = (await request.json()) as ClaimNFTRequest; + const { tagCode, fingerprintId, walletAddress } = body; + + // Rate limit check (per IP + fingerprint) - use claim rate limits + const clientId = getClientIdentifier(ipAddress, fingerprintId); + const rateLimitResult = checkRateLimit( + `claim-nft:${clientId}`, + RATE_LIMITS.claim + ); + + if (!rateLimitResult.success) { + return NextResponse.json( + { + success: false, + message: 'Terlalu banyak permintaan', + error: `Coba lagi dalam ${rateLimitResult.retryAfter} detik.`, + }, + { + status: 429, + headers: getRateLimitHeaders(rateLimitResult), + } + ); + } + + // Validate required fields + if (!tagCode || !fingerprintId || !walletAddress) { + return NextResponse.json( + { + success: false, + message: 'Data tidak lengkap', + error: 'Tag code, fingerprint ID, dan wallet address diperlukan', + }, + { status: 400 } + ); + } + + // Validate wallet address format + if (!ethers.isAddress(walletAddress)) { + return NextResponse.json( + { + success: false, + message: 'Alamat wallet tidak valid', + error: 'Silakan gunakan alamat wallet Ethereum yang valid', + }, + { status: 400 } + ); + } + + // Find the tag + const tag = await prisma.tag.findUnique({ + where: { code: tagCode }, + include: { + nft: true, + }, + }); + + if (!tag) { + return NextResponse.json( + { + success: false, + message: 'Tag tidak ditemukan', + error: 'Tag tidak ditemukan', + }, + { status: 404 } + ); + } + + // Check if tag is stamped + if (tag.is_stamped !== 1) { + return NextResponse.json( + { + success: false, + message: 'Tag belum di-stamp', + error: + 'NFT hanya tersedia untuk tag yang sudah di-stamp ke blockchain', + }, + { status: 400 } + ); + } + + // Check if NFT already minted (database check) + if (tag.nft) { + return NextResponse.json( + { + success: false, + message: 'NFT sudah di-mint', + error: 'NFT untuk tag ini sudah di-claim oleh pengguna lain', + }, + { status: 400 } + ); + } + + // Also check on-chain + const alreadyMinted = await isTagNFTMinted(tagCode); + if (alreadyMinted) { + return NextResponse.json( + { + success: false, + message: 'NFT sudah di-mint', + error: 'NFT untuk tag ini sudah ada di blockchain', + }, + { status: 400 } + ); + } + + // Verify user has a first-hand claim on this tag + const firstHandClaim = await prisma.tagScan.findFirst({ + where: { + tag_id: tag.id, + fingerprint_id: fingerprintId, + is_claimed: 1, + is_first_hand: 1, + }, + orderBy: { + created_at: 'desc', + }, + }); + + if (!firstHandClaim) { + return NextResponse.json( + { + success: false, + message: 'Klaim tidak valid', + error: + 'Anda harus melakukan klaim sebagai pemilik pertama (first-hand) terlebih dahulu', + }, + { status: 400 } + ); + } + + // Process NFT claim + console.log('Processing NFT claim for:', { tagCode, walletAddress }); + const result = await processNFTClaim(tagCode, walletAddress); + + if (!result.success) { + return NextResponse.json( + { + success: false, + message: 'Gagal mint NFT', + error: result.error || 'Terjadi kesalahan saat mint NFT', + }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + message: + 'Selamat! NFT Collectible Anda berhasil di-mint dan dikirim ke wallet Anda.', + nft: { + tokenId: result.tokenId!, + imageUrl: result.imageUrl!, + metadataUrl: result.metadataUrl!, + mintTxHash: result.mintTxHash!, + }, + }); + } catch (error) { + console.error('Claim NFT error:', error); + return NextResponse.json( + { + success: false, + message: 'Terjadi kesalahan', + error: 'Terjadi kesalahan saat memproses klaim NFT', + }, + { status: 500 } + ); + } +} + +/** + * GET /api/scan/claim-nft?tagCode=XXX + * + * Check if NFT is available to claim for a tag + */ +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const tagCode = searchParams.get('tagCode'); + + if (!tagCode) { + return NextResponse.json( + { error: 'tagCode parameter is required' }, + { status: 400 } + ); + } + + try { + const tag = await prisma.tag.findUnique({ + where: { code: tagCode }, + include: { + nft: true, + }, + }); + + if (!tag) { + return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + } + + const nftMinted = !!tag.nft || (await isTagNFTMinted(tagCode)); + + return NextResponse.json({ + tagCode, + isStamped: tag.is_stamped === 1, + nftAvailable: tag.is_stamped === 1 && !nftMinted, + nftMinted, + nft: tag.nft + ? { + tokenId: tag.nft.token_id, + imageUrl: tag.nft.image_url, + ownerAddress: tag.nft.owner_address, + } + : null, + }); + } catch (error) { + console.error('Check NFT availability error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/scan/route.ts b/src/app/api/scan/route.ts index 9c7a6f4..91022a2 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, @@ -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..3e1a7c5 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, @@ -277,23 +277,9 @@ export async function GET(request: NextRequest) { riskScore += 40; } - // Check location mismatch - let locationMismatch = false; - if (distributionInfo.country && scanLocations.length > 0) { - const distributionCountry = distributionInfo.country.toLowerCase(); - const hasLocationOutsideDistribution = scanLocations.some( - (loc) => !loc.toLowerCase().includes(distributionCountry) - ); - if (hasLocationOutsideDistribution) { - locationMismatch = true; - fraudFlags.push({ - type: 'location_mismatch', - severity: 'warning', - message: `Tag dipindai di luar wilayah distribusi resmi (${distributionInfo.country})`, - }); - riskScore += 20; - } - } + // Location mismatch detection is handled by AI analysis for better accuracy + // AI understands geographic context better than simple string matching + const locationMismatch = false; // Check suspicious scan pattern (too many unique scanners) let suspiciousScanPattern = false; diff --git a/src/app/explorer/components/events-table.tsx b/src/app/explorer/components/events-table.tsx new file mode 100644 index 0000000..17d9479 --- /dev/null +++ b/src/app/explorer/components/events-table.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { RefreshCw, ExternalLink } from 'lucide-react'; +import type { ExplorerResponse } from '@/app/api/explorer/route'; + +type ContractEvent = NonNullable[0]; + +const BASESCAN_URL = 'https://sepolia.basescan.org'; + +type EventsTableProps = { + events: ContractEvent[]; + loading: boolean; +}; + +const truncate = (str: string, start: number = 6, end: number = 4) => { + if (str.length <= start + end) return str; + return `${str.slice(0, start)}...${str.slice(-end)}`; +}; + +const getEventBadgeClass = (eventName: string) => { + switch (eventName) { + case 'TagCreated': + return 'bg-[#2B4C7E]/10 text-[#2B4C7E] border-0'; + case 'TagRevoked': + return 'bg-red-100 text-red-800 border-0'; + default: + return 'bg-amber-100 text-amber-800 border-0'; + } +}; + +export function EventsTable({ events, loading }: EventsTableProps) { + return ( + + + Contract Events + + Events emitted by the Etags contract + + + + {loading ? ( +
+ +
+ ) : events.length === 0 ? ( +
+ No events found +
+ ) : ( +
+ + + + Event + Block + Transaction + Arguments + + + + {events.map((event, index) => ( + + + + {event.event} + + + + {event.blockNumber} + + +
+ + {truncate(event.transactionHash)} + + + + +
+
+ +
+                        {JSON.stringify(event.args, null, 2)}
+                      
+
+
+ ))} +
+
+
+ )} +
+
+ ); +} diff --git a/src/app/explorer/components/explorer-header.tsx b/src/app/explorer/components/explorer-header.tsx new file mode 100644 index 0000000..d9e7261 --- /dev/null +++ b/src/app/explorer/components/explorer-header.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Blocks, RefreshCw } from 'lucide-react'; + +type ExplorerHeaderProps = { + loading: boolean; + onRefresh: () => void; +}; + +export function ExplorerHeader({ loading, onRefresh }: ExplorerHeaderProps) { + return ( +
+
+
+
+ +
+

+ Etags Explorer +

+
+

+ Blockchain transaction explorer for Etags contract +

+
+ +
+ ); +} diff --git a/src/app/explorer/components/nfts-grid.tsx b/src/app/explorer/components/nfts-grid.tsx new file mode 100644 index 0000000..74909d6 --- /dev/null +++ b/src/app/explorer/components/nfts-grid.tsx @@ -0,0 +1,230 @@ +'use client'; + +import Image from 'next/image'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + ChevronLeft, + ChevronRight, + ExternalLink, + Gem, + Loader2, + Copy, + Check, +} from 'lucide-react'; +import type { PublicNFT } from '@/app/api/explorer/route'; +import { BLOCKCHAIN_CONFIG } from '@/lib/constants'; + +interface NFTsGridProps { + nfts: PublicNFT[]; + loading: boolean; + page: number; + hasMore: boolean; + total: number; + copiedText: string | null; + onCopy: (text: string) => void; + onPageChange: (page: number) => void; +} + +function formatAddress(address: string): string { + if (!address) return ''; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString('id-ID', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} + +function getTxUrl(txHash: string): string { + return `${BLOCKCHAIN_CONFIG.EXPLORER_URL}/tx/${txHash}`; +} + +export function NFTsGrid({ + nfts, + loading, + page, + hasMore, + total, + copiedText, + onCopy, + onPageChange, +}: NFTsGridProps) { + if (loading) { + return ( + + + + + + ); + } + + if (nfts.length === 0) { + return ( + + + +

+ Belum Ada NFT +

+

+ NFT Collectible akan muncul di sini setelah pemilik pertama produk + mengklaim koleksi digital mereka. +

+
+
+ ); + } + + return ( +
+ {/* Stats */} +
+
+ + + {total} NFT Collectible + +
+
+ + {/* Grid */} +
+ {nfts.map((nft) => ( + + {/* Image */} +
+ {nft.imageUrl ? ( + {`NFT + ) : ( +
+ +
+ )} + + {/* Token ID Badge */} +
+ + #{nft.tokenId} + +
+ + {/* View on Explorer */} + {nft.mintTxHash && ( + + + + BaseScan + + + )} +
+ + {/* Info */} + + {/* Product & Brand */} +
+ {nft.productName && ( +

+ {nft.productName} +

+ )} + {nft.brandName && ( +

{nft.brandName}

+ )} + {!nft.productName && !nft.brandName && ( +

+ Etags Collectible +

+ )} +
+ + {/* Tag Code */} +
+ + {nft.tagCode} + +
+ + {/* Owner */} +
+ Owner + +
+ + {/* Date */} +
+ Minted + + {formatDate(nft.createdAt)} + +
+
+
+ ))} +
+ + {/* Pagination */} +
+

+ Halaman {page} dari {Math.ceil(total / 12)} +

+
+ + +
+
+
+ ); +} diff --git a/src/app/explorer/components/search-card.tsx b/src/app/explorer/components/search-card.tsx new file mode 100644 index 0000000..7127ea4 --- /dev/null +++ b/src/app/explorer/components/search-card.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { + Search, + RefreshCw, + Hash, + Tag, + CheckCircle2, + XCircle, + Clock, +} from 'lucide-react'; +import type { ExplorerResponse } from '@/app/api/explorer/route'; + +type Transaction = NonNullable[0]; +type TagDetails = NonNullable; + +type SearchCardProps = { + searchQuery: string; + onSearchQueryChange: (query: string) => void; + onSearch: () => void; + searchLoading: boolean; + searchError: string | null; + searchResult: TagDetails | Transaction | null; +}; + +// Helper functions +const truncate = (str: string, start: number = 10, end: number = 8) => { + if (str.length <= start + end) return str; + return `${str.slice(0, start)}...${str.slice(-end)}`; +}; + +const isTransaction = ( + result: TagDetails | Transaction +): result is Transaction => { + return 'hash' in result && 'from' in result; +}; + +const getStatusBadge = (status: string) => { + switch (status) { + case 'success': + return ( + + + Success + + ); + case 'failed': + return ( + + + Failed + + ); + default: + return ( + + + Pending + + ); + } +}; + +const getMethodBadge = (methodName: string) => { + const colors: Record = { + createTag: 'bg-[#2B4C7E]/10 text-[#2B4C7E] border-0', + updateStatus: 'bg-amber-100 text-amber-800 border-0', + revokeTag: 'bg-red-100 text-red-800 border-0', + }; + return colors[methodName] || 'bg-[#A8A8A8]/10 text-[#808080] border-0'; +}; + +export function SearchCard({ + searchQuery, + onSearchQueryChange, + onSearch, + searchLoading, + searchError, + searchResult, +}: SearchCardProps) { + return ( + + +
+
+ + onSearchQueryChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onSearch()} + className="pl-10 border-[#2B4C7E]/20 focus:border-[#2B4C7E]/50" + /> +
+ +
+ + {/* Search Error */} + {searchError && ( +
+ {searchError} +
+ )} + + {/* Search Result */} + {searchResult && ( +
+ {isTransaction(searchResult) ? ( +
+

+ + Transaction Details +

+
+
+ Hash: + + {truncate(searchResult.hash)} + +
+
+ Method: + + {searchResult.methodName} + +
+
+ Status: + {getStatusBadge(searchResult.status)} +
+ {searchResult.decodedInput && ( +
+ Decoded Input: +
+                        {JSON.stringify(searchResult.decodedInput, null, 2)}
+                      
+
+ )} +
+
+ ) : ( +
+

+ + Tag Details +

+
+
+ Tag ID: + + {truncate(searchResult.tagId)} + +
+
+ Valid: + {searchResult.isValid ? ( + + Valid + + ) : ( + Invalid + )} +
+ {searchResult.statusLabel && ( +
+ Status: + + {searchResult.statusLabel} + +
+ )} + {searchResult.createdAt && ( +
+ Created: + + {new Date(searchResult.createdAt).toLocaleString( + 'id-ID' + )} + +
+ )} + {searchResult.metadataURI && ( + + )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/app/explorer/components/stats-cards.tsx b/src/app/explorer/components/stats-cards.tsx new file mode 100644 index 0000000..a673ae2 --- /dev/null +++ b/src/app/explorer/components/stats-cards.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Tag, + Activity, + Hash, + CheckCircle2, + Copy, + ExternalLink, +} from 'lucide-react'; +import type { ExplorerResponse } from '@/app/api/explorer/route'; + +type ContractStats = NonNullable; + +const BASESCAN_URL = 'https://sepolia.basescan.org'; + +type StatsCardsProps = { + stats: ContractStats; + copiedText: string | null; + onCopy: (text: string) => void; +}; + +const truncate = (str: string, start: number = 8, end: number = 6) => { + if (str.length <= start + end) return str; + return `${str.slice(0, start)}...${str.slice(-end)}`; +}; + +export function StatsCards({ stats, copiedText, onCopy }: StatsCardsProps) { + return ( +
+ + +
+
+ +
+
+

Total Tags

+

+ {stats.totalTags} +

+
+
+
+
+ + + +
+
+ +
+
+

Network

+

+ {stats.network} +

+
+
+
+
+ + + +
+
+ +
+
+

Chain ID

+

+ {stats.chainId} +

+
+
+
+
+ + + +
+

Contract Address

+
+ + {truncate(stats.contractAddress)} + + + + + +
+
+
+
+
+ ); +} diff --git a/src/app/explorer/components/transactions-table.tsx b/src/app/explorer/components/transactions-table.tsx new file mode 100644 index 0000000..300af33 --- /dev/null +++ b/src/app/explorer/components/transactions-table.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + RefreshCw, + Copy, + CheckCircle2, + XCircle, + Clock, + ExternalLink, + ChevronLeft, + ChevronRight, +} from 'lucide-react'; +import type { ExplorerResponse } from '@/app/api/explorer/route'; + +type Transaction = NonNullable[0]; + +const BASESCAN_URL = 'https://sepolia.basescan.org'; + +type TransactionsTableProps = { + transactions: Transaction[]; + loading: boolean; + page: number; + hasMore: boolean; + copiedText: string | null; + onCopy: (text: string) => void; + onPageChange: (page: number) => void; +}; + +const truncate = (str: string, start: number = 6, end: number = 4) => { + if (str.length <= start + end) return str; + return `${str.slice(0, start)}...${str.slice(-end)}`; +}; + +const formatTime = (timestamp: number) => { + return new Date(timestamp).toLocaleString('id-ID'); +}; + +const getStatusBadge = (status: string) => { + switch (status) { + case 'success': + return ( + + + Success + + ); + case 'failed': + return ( + + + Failed + + ); + default: + return ( + + + Pending + + ); + } +}; + +const getMethodBadge = (methodName: string) => { + const colors: Record = { + createTag: 'bg-[#2B4C7E]/10 text-[#2B4C7E] border-0', + updateStatus: 'bg-amber-100 text-amber-800 border-0', + revokeTag: 'bg-red-100 text-red-800 border-0', + }; + return colors[methodName] || 'bg-[#A8A8A8]/10 text-[#808080] border-0'; +}; + +export function TransactionsTable({ + transactions, + loading, + page, + hasMore, + copiedText, + onCopy, + onPageChange, +}: TransactionsTableProps) { + const router = useRouter(); + + return ( + + + Recent Transactions + + Transactions interacting with the Etags contract + + + + {loading ? ( +
+ +
+ ) : transactions.length === 0 ? ( +
+ No transactions found +
+ ) : ( + <> +
+ + + + Tx Hash + Block + Method + From + Status + Time + + + + + {transactions.map((tx) => ( + + +
+ + {truncate(tx.hash)} + + +
+
+ + {tx.blockNumber} + + + + {tx.methodName} + + + + + {truncate(tx.from)} + + + {getStatusBadge(tx.status)} + + {formatTime(tx.timestamp)} + + +
+ + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} +
+

Page {page}

+
+ + +
+
+ + )} +
+
+ ); +} diff --git a/src/app/explorer/page.tsx b/src/app/explorer/page.tsx index 3a919d6..4c77e0a 100644 --- a/src/app/explorer/page.tsx +++ b/src/app/explorer/page.tsx @@ -1,61 +1,37 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { - Search, - ExternalLink, - Copy, - CheckCircle2, - XCircle, - Clock, - Hash, - Blocks, - Activity, - Tag, - RefreshCw, - ChevronLeft, - ChevronRight, - FileCode, -} from 'lucide-react'; -import type { ExplorerResponse } from '@/app/api/explorer/route'; +import { FileCode, Activity, Gem } from 'lucide-react'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { ExplorerHeader } from './components/explorer-header'; +import { SearchCard } from './components/search-card'; +import { StatsCards } from './components/stats-cards'; +import { TransactionsTable } from './components/transactions-table'; +import { EventsTable } from './components/events-table'; +import { NFTsGrid } from './components/nfts-grid'; +import type { ExplorerResponse, PublicNFT } from '@/app/api/explorer/route'; type ContractStats = NonNullable; type Transaction = NonNullable[0]; type ContractEvent = NonNullable[0]; type TagDetails = NonNullable; -const BASESCAN_URL = 'https://sepolia.basescan.org'; - export default function ExplorerPage() { - const router = useRouter(); const [stats, setStats] = useState(null); const [transactions, setTransactions] = useState([]); const [events, setEvents] = useState([]); + const [nfts, setNfts] = useState([]); const [loading, setLoading] = useState(true); const [txLoading, setTxLoading] = useState(false); const [eventsLoading, setEventsLoading] = useState(false); + const [nftsLoading, setNftsLoading] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(false); + const [nftPage, setNftPage] = useState(1); + const [nftHasMore, setNftHasMore] = useState(false); + const [nftTotal, setNftTotal] = useState(0); const [searchQuery, setSearchQuery] = useState(''); const [searchResult, setSearchResult] = useState< TagDetails | Transaction | null @@ -112,15 +88,40 @@ export default function ExplorerPage() { } }, []); + // Fetch NFTs + const fetchNfts = useCallback(async (pageNum: number = 1) => { + setNftsLoading(true); + try { + const response = await fetch( + `/api/explorer?action=nfts&page=${pageNum}&pageSize=12` + ); + const data: ExplorerResponse = await response.json(); + if (data.success && data.nfts) { + setNfts(data.nfts); + setNftHasMore(data.pagination?.hasMore || false); + setNftTotal(data.pagination?.total || 0); + } + } catch (error) { + console.error('Failed to fetch NFTs:', error); + } finally { + setNftsLoading(false); + } + }, []); + // Initial load useEffect(() => { const init = async () => { setLoading(true); - await Promise.all([fetchStats(), fetchTransactions(1), fetchEvents()]); + await Promise.all([ + fetchStats(), + fetchTransactions(1), + fetchEvents(), + fetchNfts(1), + ]); setLoading(false); }; init(); - }, [fetchStats, fetchTransactions, fetchEvents]); + }, [fetchStats, fetchTransactions, fetchEvents, fetchNfts]); // Handle search const handleSearch = async () => { @@ -169,536 +170,126 @@ export default function ExplorerPage() { setTimeout(() => setCopiedText(null), 2000); }; - // Truncate address/hash - const truncate = (str: string, start: number = 6, end: number = 4) => { - if (str.length <= start + end) return str; - return `${str.slice(0, start)}...${str.slice(-end)}`; + // Handle page change + const handlePageChange = (newPage: number) => { + setPage(newPage); + fetchTransactions(newPage); }; - // Format timestamp - const formatTime = (timestamp: number) => { - return new Date(timestamp).toLocaleString('id-ID'); + // Handle NFT page change + const handleNftPageChange = (newPage: number) => { + setNftPage(newPage); + fetchNfts(newPage); }; - // Get status badge - const getStatusBadge = (status: string) => { - switch (status) { - case 'success': - return ( - - - Success - - ); - case 'failed': - return ( - - - Failed - - ); - default: - return ( - - - Pending - - ); - } - }; - - // Get method badge color - const getMethodBadge = (methodName: string) => { - const colors: Record = { - createTag: 'bg-blue-100 text-blue-800', - updateStatus: 'bg-yellow-100 text-yellow-800', - revokeTag: 'bg-red-100 text-red-800', - }; - return colors[methodName] || 'bg-gray-100 text-gray-800'; - }; - - // Check if search result is a transaction - const isTransaction = ( - result: TagDetails | Transaction - ): result is Transaction => { - return 'hash' in result && 'from' in result; + // Handle refresh + const handleRefresh = () => { + fetchStats(); + fetchTransactions(page); + fetchEvents(); + fetchNfts(nftPage); }; return ( -
- {/* Header */} -
-
-
-
-

- - Etags Explorer -

-

- Blockchain transaction explorer for Etags contract -

-
- -
-
+
+ {/* Background Effects */} +
+
+
-
- {/* Search Bar */} - - -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - className="pl-10" - /> -
- -
+ - {/* Search Error */} - {searchError && ( -
- {searchError} -
- )} +
+
+ {/* Header */} + - {/* Search Result */} - {searchResult && ( -
- {isTransaction(searchResult) ? ( -
-

- - Transaction Details -

-
-
- Hash: - - {truncate(searchResult.hash, 10, 8)} - -
-
- Method: - - {searchResult.methodName} - -
-
- Status: - {getStatusBadge(searchResult.status)} -
- {searchResult.decodedInput && ( -
- Decoded Input: -
-                            {JSON.stringify(searchResult.decodedInput, null, 2)}
-                          
-
- )} -
-
- ) : ( -
-

- - Tag Details -

-
-
- Tag ID: - - {truncate(searchResult.tagId, 10, 8)} - -
-
- Valid: - {searchResult.isValid ? ( - - Valid - - ) : ( - Invalid - )} -
- {searchResult.statusLabel && ( -
- Status: - - {searchResult.statusLabel} - -
- )} - {searchResult.createdAt && ( -
- Created: - - {new Date(searchResult.createdAt).toLocaleString( - 'id-ID' - )} - -
- )} - {searchResult.metadataURI && ( - - )} -
-
- )} -
- )} - - + {/* Search Bar */} + - {/* Stats Cards */} - {stats && ( -
- - -
-
- -
-
-

Total Tags

-

{stats.totalTags}

-
-
-
-
+ {/* Stats Cards */} + {stats && ( + + )} - - -
-
- -
-
-

Network

-

{stats.network}

-
-
-
-
- - - -
-
- -
-
-

Chain ID

-

{stats.chainId}

-
-
-
-
- - - -
-

Contract Address

-
- - {truncate(stats.contractAddress, 8, 6)} - - - - - -
-
-
-
-
- )} + {/* Tabs for Transactions, Events, and NFTs */} + +
+ + + + Transactions + Txns + + + + Events + + + + NFT Collectibles + NFTs + + +
- {/* Tabs for Transactions and Events */} - - - - - Transactions - - - - Events - - + {/* Transactions Tab */} + + + - {/* Transactions Tab */} - - - - Recent Transactions - - Transactions interacting with the Etags contract - - - - {txLoading ? ( -
- -
- ) : transactions.length === 0 ? ( -
- No transactions found -
- ) : ( - <> -
- - - - Tx Hash - Block - Method - From - Status - Time - - - - - {transactions.map((tx) => ( - - -
- - {truncate(tx.hash)} - - -
-
- {tx.blockNumber} - - - {tx.methodName} - - - - - {truncate(tx.from)} - - - {getStatusBadge(tx.status)} - - {formatTime(tx.timestamp)} - - -
- - -
-
-
- ))} -
-
-
+ {/* Events Tab */} + + + - {/* Pagination */} -
-

Page {page}

-
- - -
-
- - )} -
-
-
+ {/* NFTs Tab */} + + + +
+
+
- {/* Events Tab */} - - - - Contract Events - - Events emitted by the Etags contract - - - - {eventsLoading ? ( -
- -
- ) : events.length === 0 ? ( -
- No events found -
- ) : ( -
- - - - Event - Block - Transaction - Arguments - - - - {events.map((event, index) => ( - - - - {event.event} - - - {event.blockNumber} - -
- - {truncate(event.transactionHash)} - - - - -
-
- -
-                                {JSON.stringify(event.args, null, 2)}
-                              
-
-
- ))} -
-
-
- )} -
-
-
- -
+
); } diff --git a/src/app/faqs/page.tsx b/src/app/faqs/page.tsx new file mode 100644 index 0000000..d4d3ec7 --- /dev/null +++ b/src/app/faqs/page.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Navbar } from '@/components/landing/Navbar'; +import { Footer } from '@/components/landing/Footer'; +import { FAQContent } from '@/components/faq'; + +const MotionDiv = motion.div; + +export default function FAQsPage() { + return ( +
+ {/* Animated Background Effects */} +
+ + +
+ + + + {/* Main Content */} +
+
+ +
+
+ +
+
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/globals.css b/src/app/globals.css index 79da25f..7240d69 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -121,3 +121,24 @@ @apply bg-background text-foreground; } } + +/* Reduced motion support for older devices and accessibility */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* GPU acceleration hints for smoother animations */ +.blur-xl, +.blur-2xl, +.blur-3xl, +.blur-\[80px\] { + will-change: auto; + transform: translateZ(0); +} diff --git a/src/app/guide/page.tsx b/src/app/guide/page.tsx new file mode 100644 index 0000000..28da2f5 --- /dev/null +++ b/src/app/guide/page.tsx @@ -0,0 +1,306 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { + CheckCircle2, + Smartphone, + LogIn, + Gift, + BarChart3, + MessageSquare, + ExternalLink, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export const metadata = { + title: 'Panduan Juri - Etags IMPHNEN 2025', + description: 'Panduan cepat untuk juri hackathon IMPHNEN 2025', +}; + +export default function JudgeGuidePage() { + return ( +
+ {/* Header */} +
+
+ + Etags Logo + Etags + +
+ + IMPHNEN 2025 + +
+
+
+ +
+ {/* Hero */} +
+

+ Selamat Datang, Juri! 👋 +

+

+ Panduan singkat untuk mencoba fitur utama Etags - Platform + Verifikasi Produk Berbasis Blockchain +

+
+ + {/* Quick Start Steps */} +
+ {/* Step 1: Scan QR */} +
+
+
+ 1 +
+
+
+ +

+ Scan Tag & Klaim NFT +

+
+

+ Scan QR code produk untuk memverifikasi keaslian dan klaim NFT + koleksi Anda sebagai pemilik pertama. +

+ + {/* QR Code Display */} +
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + QR Code untuk Juri +
+
+

+ Tag Khusus Juri +

+

+ Scan dengan kamera HP atau klik tombol di bawah +

+
+ + +
+
+
+
+ +
+

+ Tips: Untuk klaim NFT, gunakan browser + dengan MetaMask dan hubungkan ke jaringan Base Sepolia. Gas + fee ditanggung sistem! +

+
+
+
+
+ + {/* Step 2: Login Dashboard */} +
+
+
+ 2 +
+
+
+ +

+ Login ke Dashboard +

+
+

+ Akses dashboard untuk melihat manajemen brand, produk, tag, + dan analitik. +

+ + {/* Credentials */} +
+
+
+ + Admin + + + ADMIN + +
+

+ admin@example.com +

+

admin123

+
+
+
+ + Brand (Juri) + + + BRAND + +
+

+ judge@hackathon.imphnen.dev +

+

+ IMPHNEN2025 +

+
+
+ + +
+
+
+ + {/* Step 3: Explore Features */} +
+
+
+ 3 +
+
+
+ +

+ Jelajahi Fitur +

+
+

+ Coba berbagai fitur yang tersedia di platform Etags. +

+ +
+ + +
+

Dashboard

+

+ Statistik & analitik +

+
+ + + +
+

+ Tag Management +

+

+ Kelola tag produk +

+
+ + + +
+

+ NFT Collectibles +

+

+ Monitor NFT yang diklaim +

+
+ + + +
+

Web3 Support

+

+ Tiket support via wallet +

+
+ +
+
+
+
+
+ + {/* Tech Stack */} +
+

Dibangun dengan

+
+ {[ + 'Next.js 16', + 'React 19', + 'TypeScript', + 'Prisma', + 'Base Sepolia', + 'ethers.js', + 'Tailwind CSS', + 'shadcn/ui', + ].map((tech) => ( + + {tech} + + ))} +
+
+ + {/* Team */} +
+

+ Tim: Pemuja Deadline Anti Refund +

+

IMPHNEN Hackathon 2025

+
+
+ + {/* Footer */} +
+
+

+ © 2025 Etags - Product Tagging & Blockchain Stamping Platform +

+
+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6159e1f..4ad0123 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,9 +14,57 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { + metadataBase: new URL('http://localhost:3000'), // TODO: Update with production URL title: 'Etags - Product Tagging & Blockchain Stamping', description: - 'Product tagging and blockchain stamping for authentication and verification', + 'Platform penandaan produk & stamping blockchain untuk autentikasi dan verifikasi keaslian produk.', + manifest: '/manifest.json', + icons: { + icon: [ + { url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' }, + { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' }, + { url: '/favicon.ico', sizes: 'any' }, + ], + apple: [ + { url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }, + ], + }, + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + title: 'Etags', + }, + formatDetection: { + telephone: false, + }, + openGraph: { + type: 'website', + siteName: 'Etags', + title: 'Etags - Product Tagging & Blockchain Stamping', + description: + 'Platform penandaan produk & stamping blockchain untuk autentikasi dan verifikasi keaslian produk.', + images: [ + { + url: '/logo.png', + width: 500, + height: 500, + alt: 'Etags Logo', + }, + ], + }, + twitter: { + card: 'summary', + title: 'Etags - Product Tagging & Blockchain Stamping', + description: + 'Platform penandaan produk & stamping blockchain untuk autentikasi dan verifikasi keaslian produk.', + images: ['/logo.png'], + }, +}; + +export const viewport = { + themeColor: '#0c0a09', + width: 'device-width', + initialScale: 1, }; import { SessionProvider } from '@/components/providers/session-provider'; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 5af9c03..1e90b35 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,6 +2,7 @@ import { useActionState } from 'react'; import Link from 'next/link'; +import Image from 'next/image'; import { login, type LoginState } from '@/lib/actions/auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -14,7 +15,7 @@ import { CardTitle, CardFooter, } from '@/components/ui/card'; -import { Shield } from 'lucide-react'; +import { Mail, Lock, ArrowRight } from 'lucide-react'; export default function LoginPage() { const [state, formAction, isPending] = useActionState( @@ -23,67 +24,206 @@ export default function LoginPage() { ); return ( -
- {/* Header */} -
- - - Etags - -

- Lindungi produk Anda dengan teknologi blockchain -

-
+
+ {/* Left side - Branding */} +
+ {/* Background pattern */} +
+
+
+
- - - Masuk - - Masukkan kredensial Anda untuk mengakses dashboard - - - -
- {state.error && ( -
- {state.error} -
- )} -
- - + +
+ Etags Logo
-
- - + Etags + +
+ +
+

+ Lindungi Produk Anda +
+ dengan Teknologi Blockchain +

+

+ Platform anti-pemalsuan terdepan yang menggunakan blockchain untuk + memastikan keaslian setiap produk Anda. +

+
+
+
10K+
+
Tag Aktif
+
+
+
500+
+
Brand Terdaftar
+
+
+
99.9%
+
Uptime
- - - - -
- Belum punya akun?{' '} - - Daftar sekarang -
-
- +
+ +
+ © {new Date().getFullYear()} Etags. All rights reserved. +
+
+ + {/* Right side - Login Form */} +
+ {/* Mobile logo */} +
+ +
+ Etags Logo +
+ + Etags + + +

+ Lindungi produk Anda dengan teknologi blockchain +

+
+ + + + + Selamat Datang + + + Masukkan kredensial Anda untuk mengakses dashboard + + + +
+ {state.error && ( +
+
+ ! +
+ {state.error} +
+ )} +
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+
+ + {/* Demo Credentials */} +
+

+ Demo Credentials +

+
+
+
+ Admin +

+ admin@example.com / admin123 +

+
+ + ADMIN + +
+
+
+ + Brand (Judge) + +

+ judge@hackathon.imphnen.dev / IMPHNEN2025 +

+
+ + BRAND + +
+
+
+ +
+ Belum punya akun?{' '} + + Daftar sekarang + +
+
+
+ + {/* Back to home */} + + ← Kembali ke beranda + +
); } 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..f654674 100644 --- a/src/app/manage/brands/page.tsx +++ b/src/app/manage/brands/page.tsx @@ -1,29 +1,66 @@ 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'; +import { Pagination } from '@/components/ui/pagination'; -async function BrandsTableWrapper() { - const { brands } = await getBrands(1, 50); - return ; +async function BrandsTableWrapper({ page }: { page: number }) { + const { brands, pagination } = await getBrands(page, 10); + return ( + <> + + + + ); +} + +async function BrandStatsWrapper() { + const stats = await getBrandStats(); + return ; +} + +function StatsCardsSkeleton() { + return ( +
+ {[...Array(4)].map((_, i) => ( + + ))} +
+ ); } -export default async function BrandsPage() { +export default async function BrandsPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string }>; +}) { const session = await auth(); if (!session?.user || session.user.role !== 'admin') { redirect('/manage'); } + const params = await searchParams; + const page = parseInt(params.page || '1', 10); + return (
+ + {/* Stats Cards */} + }> + + + + {/* Brands Table */}
}> - +
diff --git a/src/app/manage/layout.tsx b/src/app/manage/layout.tsx index 8f6ca2a..f2f0b38 100644 --- a/src/app/manage/layout.tsx +++ b/src/app/manage/layout.tsx @@ -3,6 +3,7 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { Sidebar } from './sidebar'; import Link from 'next/link'; +import Image from 'next/image'; import { Suspense } from 'react'; import { UserProfileHeader } from './user-profile-header'; import { Skeleton } from '@/components/ui/skeleton'; @@ -44,30 +45,14 @@ export default async function ManageLayout({
-
-
-
- - - - - - - - - -
+
+ Etags Logo
Etags 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 + +
+
- +
- +
- +