diff --git a/.github/workflows/deploy-ec2.yml b/.github/workflows/deploy-ec2.yml new file mode 100644 index 0000000..741fc74 --- /dev/null +++ b/.github/workflows/deploy-ec2.yml @@ -0,0 +1,115 @@ +name: Deploy to EC2 + +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + env: + NODE_ENV: production + EC2_ENV: ${{ secrets.EC2_ENV }} + + steps: + - name: Checkout repository + 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 test -- --watch=false || echo "Tests step is configurable; adjust as needed" + + - name: Build app + run: npm run build + + - name: Deploy to EC2 with rollback + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_KEY }} + port: ${{ secrets.EC2_SSH_PORT || '22' }} + script: | + set -e + + APP_DIR="${APP_DIR:-/var/www/pdc-poc}" + + echo "Using app directory: $APP_DIR" + mkdir -p "$APP_DIR" + cd "$APP_DIR" + + echo "Writing .env file from EC2_ENV secret..." + # Overwrite .env on the server with the contents of the EC2_ENV variable + # EC2_ENV is provided by GitHub as a multi-line secret matching your local .env + printf '%s\n' "$EC2_ENV" > .env + + if [ ! -d ".git" ]; then + echo "Cloning repository for the first time..." + git clone ${{ github.repositoryUrl }} . + fi + + git remote set-url origin ${{ github.repositoryUrl }} + + PREV_COMMIT=$(git rev-parse HEAD || echo "") + echo "Previous commit: $PREV_COMMIT" + + echo "Fetching latest changes..." + git fetch origin + git checkout ${{ github.ref_name }} + git reset --hard ${{ github.sha }} + + echo "Pulling docker images and rebuilding containers..." + if command -v docker-compose >/dev/null 2>&1; then + DC="docker-compose" + else + DC="docker compose" + fi + + $DC pull || true + + set +e + $DC up -d --build + DEPLOY_EXIT=$? + set -e + + if [ $DEPLOY_EXIT -ne 0 ]; then + echo "Deployment failed, attempting rollback..." + if [ -n "$PREV_COMMIT" ]; then + git reset --hard "$PREV_COMMIT" + $DC up -d --build || true + fi + exit 1 + fi + + echo "Running health check..." + HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}" + + for i in {1..10}; do + if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then + echo "Health check passed." + exit 0 + fi + echo "Health check attempt $i failed, retrying..." + sleep 5 + done + + echo "Health check failed after multiple attempts, rolling back..." + if [ -n "$PREV_COMMIT" ]; then + git reset --hard "$PREV_COMMIT" + $DC up -d --build || true + fi + exit 1 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1ab46a --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build +/dist + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# generated / local artifacts +/src/generated/prisma +/.turbo +/.vite + +# prisma +/prisma/dev.db +/prisma/dev.db-journal + +# docker +*.log +docker-data/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..15554c5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json prisma.config.ts ./ +COPY prisma ./prisma + +RUN npm install && npx prisma generate + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "run", "dev"] diff --git a/Documents/GMAIL_SETUP.md b/Documents/GMAIL_SETUP.md new file mode 100644 index 0000000..9ec4336 --- /dev/null +++ b/Documents/GMAIL_SETUP.md @@ -0,0 +1,164 @@ +# Gmail Integration Setup Guide + +This guide explains how to set up Gmail integration so that **every vendor can connect their own Gmail account** to receive and send customer emails. + +## How It Works + +- **One OAuth App**: You create ONE Google OAuth application in Google Cloud Console +- **Shared Credentials**: All vendors use the same `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` +- **Individual Authorization**: Each vendor authorizes their own Gmail account when connecting +- **Separate Tokens**: Each vendor's Gmail tokens are stored separately in the database + +## Login Method vs Gmail Integration + +**IMPORTANT**: These are TWO separate things: + +1. **Login Method** (How vendor logs into YOUR app): + - Email/Password login ✅ + - Google OAuth login ✅ + - Both work independently + +2. **Gmail Integration** (Authorizing Gmail API access): + - Separate OAuth flow + - Works regardless of login method + - Vendor can connect ANY Gmail account (doesn't need to match login email) + +### Example Scenarios: + +**Scenario 1**: Vendor logs in with email/password (`talatfaheem2@gmail.com`) +- ✅ Can connect Gmail integration +- When clicking "Connect Gmail", they'll be redirected to Google OAuth +- Can authorize the same Gmail (`talatfaheem2@gmail.com`) OR a different one (`business@gmail.com`) + +**Scenario 2**: Vendor logs in with Google OAuth (`talatfaheem2@gmail.com`) +- ✅ Can connect Gmail integration +- When clicking "Connect Gmail", they'll be redirected to Google OAuth again +- Can authorize the same Gmail (`talatfaheem2@gmail.com`) OR a different one (`business@gmail.com`) + +**Key Point**: The Gmail account they authorize doesn't need to match their login email! + +## Step-by-Step Setup + +### 1. Create Google Cloud Project (One Time Setup) + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project (or select existing) +3. Enable **Gmail API**: + - Go to [APIs & Services > Library](https://console.cloud.google.com/apis/library) + - Search for "Gmail API" + - Click "Enable" + +### 2. Create OAuth 2.0 Credentials + +1. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials) +2. Click "Create Credentials" > "OAuth client ID" +3. **If prompted**, configure OAuth consent screen (Google will prompt you the first time): + - Choose "External" (unless you have Google Workspace) + - Fill in app name, support email, developer contact + - **Note**: You don't need to manually add scopes here - they're automatically requested when vendors connect + - **IMPORTANT**: Add test users - Add ALL Gmail addresses that will connect (e.g., `krypttech4@gmail.com`, `talatfaheem2@gmail.com`, etc.) + - Click "Save and Continue" through the steps + + **OR if consent screen is already configured:** + - Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) + - Scroll down to "Test users" section + - Click "+ ADD USERS" + - Add the Gmail addresses that will connect (one per line or comma-separated) + - Click "Add" +4. Create OAuth client: + - Application type: "Web application" + - Name: "JunoHub Gmail Integration" (or your choice) + - **Authorized redirect URIs** (CRITICAL - must match exactly): + ``` + http://localhost:3000/api/integrations/gmail/callback + https://your-production-domain.com/api/integrations/gmail/callback + ``` + - If using ngrok: `https://your-ngrok-url.ngrok-free.dev/api/integrations/gmail/callback` + - **Important**: Add ALL environments you'll use (dev, staging, production, ngrok URLs) + +### 3. Configure Environment Variables + +Add to your `.env` file: + +```env +# Your Google Cloud OAuth credentials (shared for all vendors) +GOOGLE_CLIENT_ID="your-client-id-here.apps.googleusercontent.com" +GOOGLE_CLIENT_SECRET="your-client-secret-here" + +# Your app URL (used for OAuth redirects) +NEXTAUTH_URL="http://localhost:3000" # or your production URL +``` + +### 4. Test the Integration + +1. Start your application +2. Log in as a vendor +3. Go to Juno Engine > Gmail Connect +4. Click "Connect Gmail Account" +5. Sign in with **any Gmail account** (doesn't need to match the OAuth app owner) +6. Authorize the requested permissions +7. You should be redirected back and see "Gmail Connected" + +## Troubleshooting + +### Error: `redirect_uri_mismatch` + +**Problem**: The redirect URI in your code doesn't match what's configured in Google Cloud Console. + +**Solution**: +1. Check the error message - it will show the redirect URI being used +2. Go to Google Cloud Console > Credentials > Your OAuth Client +3. Add the exact redirect URI shown in the error to "Authorized redirect URIs" +4. Wait a few minutes for changes to propagate +5. Try again + +### Error: `invalid_client` + +**Problem**: The `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is incorrect. + +**Solution**: +1. Double-check your `.env` file +2. Make sure there are no extra spaces or quotes +3. Restart your application after changing `.env` + +### Error: `access_denied` or "Access blocked: has not completed the Google verification process" + +**Problem**: The OAuth app is in "Testing" mode and the user's Gmail address is not added as a test user. + +**Solution**: +1. Go to [APIs & Services > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) +2. Scroll down to "Test users" section +3. Click "+ ADD USERS" +4. Add the Gmail address that's trying to connect (e.g., `krypttech4@gmail.com`) +5. Click "Add" +6. Wait a few minutes for changes to propagate +7. Try connecting again + +**Note**: In testing mode, you must add each Gmail address as a test user. For production, you'll need to publish your app (requires Google verification). + +## Important Notes + +- **OAuth Consent Screen**: Google will prompt you to configure this the FIRST time you create OAuth credentials. After that, you can skip it. The scopes are automatically requested in the OAuth flow - you don't need to manually add them in the consent screen. +- **OAuth App Owner**: The email that created the OAuth app (`i221735@nu.edu.pk`) doesn't matter. Any Gmail account can authorize access. +- **Vendor Gmail**: Each vendor connects their own Gmail account (`talatfaheem2@gmail.com`, etc.). They don't need their own OAuth credentials. +- **Redirect URI**: Must match EXACTLY (including protocol, domain, port, path). No trailing slashes. +- **Multiple Environments**: Add redirect URIs for all environments (localhost, staging, production, ngrok). + +## Security Considerations + +- OAuth tokens are stored securely in the database +- Each vendor's tokens are isolated (can't access each other's emails) +- Tokens are automatically refreshed when expired +- Vendors can disconnect their Gmail at any time + +## Production Checklist + +- [ ] Gmail API enabled in Google Cloud Console +- [ ] OAuth consent screen configured +- [ ] **Test users added** (for testing mode) - Add all Gmail addresses that will connect +- [ ] OAuth client created with correct redirect URIs +- [ ] Environment variables set (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `NEXTAUTH_URL`) +- [ ] Database migration run (`prisma db push`) +- [ ] Tested with at least one vendor account +- [ ] Production redirect URI added to Google Cloud Console +- [ ] **For production**: Publish your app (requires Google verification) OR keep adding test users as needed diff --git a/Documents/REMOVE_OAUTH_WARNING.md b/Documents/REMOVE_OAUTH_WARNING.md new file mode 100644 index 0000000..dd5b9c1 --- /dev/null +++ b/Documents/REMOVE_OAUTH_WARNING.md @@ -0,0 +1,154 @@ +# How to Remove "Google hasn't verified this app" Warning + +## Quick Answer + +The warning appears because your app requests **Gmail scopes** which are sensitive/restricted and require Google verification. You have 3 options: + +## Option 1: Keep in Testing Mode (Easiest - For Development) + +**Best for**: Development, testing, or apps with fewer than 100 users + +### Steps: +1. Go to [Google Cloud Console > OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) +2. Click **"Back to testing"** button (if your app is currently "In production") +3. Add all Gmail addresses as **test users**: + - Scroll to "Test users" section + - Click "+ ADD USERS" + - Add each Gmail address (one per line) + - Click "Add" +4. Save changes + +### Result: +- ✅ Test users won't see the warning +- ✅ Can connect Gmail without verification +- ❌ Limited to 100 test users +- ❌ Must manually add each user + +--- + +## Option 2: Get Google Verification (For Production) + +**Best for**: Production apps that need to scale beyond 100 users + +### Prerequisites: +Before starting verification, you need: + +1. **Privacy Policy** (Required) + - Must be publicly accessible URL + - Must explain how you use Gmail data + - Example: `https://yourdomain.com/privacy-policy` + +2. **Terms of Service** (Required) + - Must be publicly accessible URL + - Example: `https://yourdomain.com/terms` + +3. **App Homepage** (Required) + - Your app's main website URL + - Example: `https://yourdomain.com` + +4. **Authorized Domains** + - Add your production domain + - Example: `yourdomain.com` + +### Verification Steps: + +1. **Go to Verification Center**: + - [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) + - Click "Verification Center" in left sidebar + - Or go directly: [Verification Center](https://console.cloud.google.com/apis/credentials/consent/verification) + +2. **Start Verification**: + - Click "Start Verification" or "Submit for verification" + - Fill out the verification form + +3. **Complete Verification Form**: + - **App name**: Your app name (e.g., "JunoHub") + - **Support email**: Your support email address + - **App domain**: Your production domain + - **Authorized domains**: Add your domain + - **Scopes**: Gmail scopes will be automatically listed + - **Privacy Policy URL**: Your privacy policy URL + - **Terms of Service URL**: Your terms of service URL + - **App homepage**: Your app's homepage URL + - **App logo**: Optional but recommended + +4. **Additional Information** (may be requested): + - **Video demonstration**: Show how your app uses Gmail + - **Use case explanation**: Why you need Gmail access + - **Data handling**: How you store/process Gmail data + +5. **Submit for Review**: + - Review all information + - Submit the form + - Google will review (typically 4-6 weeks) + +### After Verification: +- ✅ Warning disappears for ALL users +- ✅ No user limit +- ✅ Can request sensitive/restricted scopes +- ✅ Professional appearance + +--- + +## Option 3: Use Domain-Wide Delegation (Advanced - For Google Workspace) + +**Best for**: Google Workspace organizations only + +If you're using Google Workspace, you can use domain-wide delegation instead of OAuth, but this requires admin access and is more complex. + +--- + +## Current Status Check + +Based on your Google Cloud Console screenshot: +- ✅ App is "In production" +- ✅ User type is "External" +- ⚠️ OAuth user cap: 0/100 users +- ⚠️ Warning appears because Gmail scopes are unapproved + +## Recommended Action + +**For now (Development/Testing)**: +1. Click "Back to testing" in OAuth consent screen +2. Add test users (your vendors' Gmail addresses) +3. Test users won't see the warning + +**For later (Production)**: +1. Create Privacy Policy and Terms of Service pages +2. Submit app for Google verification +3. Wait for approval (4-6 weeks) +4. Warning will disappear automatically + +--- + +## Privacy Policy Template (Gmail Data Usage) + +Your Privacy Policy must mention Gmail data usage. Example section: + +```markdown +## Gmail Integration + +When you connect your Gmail account to [Your App Name], we access: +- Read emails: To sync customer emails and create support tickets +- Send emails: To send automated responses and notifications +- Modify emails: To manage email threads and mark emails as read + +We store: +- OAuth tokens (encrypted) for Gmail API access +- Email addresses for account identification + +We do NOT: +- Store email content permanently +- Share your emails with third parties +- Access emails outside of the app's functionality + +You can revoke access at any time by disconnecting Gmail in your account settings. +``` + +--- + +## Need Help? + +- [Google OAuth Verification Guide](https://support.google.com/cloud/answer/9110914) +- [OAuth Consent Screen Documentation](https://developers.google.com/identity/protocols/oauth2/policy) +- [Gmail API Scopes Documentation](https://developers.google.com/gmail/api/auth/scopes) diff --git a/Documents/faq_test.json b/Documents/faq_test.json new file mode 100644 index 0000000..4218305 --- /dev/null +++ b/Documents/faq_test.json @@ -0,0 +1,28 @@ +[ + { + "question": "What is your standard order processing time?", + "answer": "Orders are typically processed within 1–2 business days. During peak seasons, processing may take up to 3 business days.", + "tags": ["orders", "shipping", "processing_time"] + }, + { + "question": "How long does shipping usually take?", + "answer": "Standard shipping within the US takes 3–7 business days after the order has been processed. International shipping can take 7–21 business days depending on the destination and carrier.", + "tags": ["shipping", "delivery"] + }, + { + "question": "What is your return policy?", + "answer": "You can return most items within 30 days of delivery in their original condition for a full refund. Items marked as final sale are not eligible for returns.", + "tags": ["returns", "refunds", "policy"] + }, + { + "question": "How do I start a return?", + "answer": "To start a return, go to your Orders page, select the order you want to return, and click \"Request Return\". Our team will email you a prepaid return label and instructions within 24 hours.", + "tags": ["returns", "how_to"] + }, + { + "question": "Can I change or cancel my order after placing it?", + "answer": "Orders can be changed or canceled within 1 hour of placement. After that, we start processing and cannot guarantee changes. Please contact support as soon as possible with your order number.", + "tags": ["orders", "changes", "cancellations"] + } +] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e88546 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +## JUNOHUB + +JUNOHUB is a Next.js application that powers an admin and supplier portal with: + +- Authentication and multi-factor auth +- Gmail integration for support tickets (Juno Engine) +- Shopify integration for orders, products, and inventory +- Knowledge base and FAQ management +- Admin dashboards for stores, suppliers, and email events + +The frontend and backend are implemented in a single Next.js App Router project. + +--- + +## Tech Stack + +- **Framework**: Next.js (App Router, TypeScript) +- **UI**: React + modern Tailwind-style components +- **Auth**: NextAuth +- **Database**: PostgreSQL (Prisma ORM) +- **Background / Integrations**: + - Gmail APIs (via OAuth) + - Shopify APIs + - Gemini for embeddings and chat +- **Deployment**: Docker + GitHub Actions → AWS EC2 + +--- + +## Local Development + +1. **Install dependencies** + +```bash +npm install +``` + +2. **Create `.env`** + +Copy `.env.example` (or the variables listed below) into a local `.env` file and fill in your own values: + +- `DATABASE_URL` +- `NEXTAUTH_SECRET` +- `NEXTAUTH_URL` +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- `SMTP_HOST` +- `SMTP_PORT` +- `SMTP_SECURE` +- `SMTP_USER` +- `SMTP_PASSWORD` +- `GEMINI_API_KEY` +- `GEMINI_EMBEDDING_MODEL` +- `GEMINI_CHAT_MODEL` +- `ADMIN_EMAIL` +- `ADMIN_PASSWORD` + +> Never commit real secrets; `.env*` files are ignored by git. + +3. **Run the dev server** + +```bash +npm run dev +``` + +Visit `http://localhost:3000` in your browser. + +--- + +## Docker + +The project can be run via Docker using the provided `Dockerfile` and `docker-compose.yml`. + +Typical pattern: + +```bash +docker compose up -d --build +``` + +Make sure your `.env` is present next to the app code so the container can read it. + +--- + +## CI/CD on AWS EC2 + +This repo includes a GitHub Actions workflow at `.github/workflows/deploy-ec2.yml` that: + +1. Runs on pushes to the `main` branch. +2. Installs dependencies and builds the Next.js app. +3. SSHes into an EC2 instance. +4. Writes an `.env` file on the EC2 instance from a **single GitHub secret** `EC2_ENV`. +5. Uses `docker compose` / `docker-compose` to build and run the app. +6. Performs a health check and **automatically rolls back** to the previous commit if the deployment or health check fails. + +### Required GitHub Secrets + +Set the following secrets in **GitHub → Settings → Secrets and variables → Actions**: + +- `EC2_HOST` – public IP or DNS of the EC2 instance. +- `EC2_USER` – SSH user (e.g. `ubuntu`). +- `EC2_SSH_KEY` – private SSH key for that user. +- `EC2_SSH_PORT` – (optional) port, defaults to `22` if not set. +- `EC2_ENV` – **multi-line secret containing the full contents of the `.env` file** used in production. + This is written to `.env` on the server during deployment. + +### Deployment Flow + +- Push to `main`. +- GitHub Actions: + - Builds the app. + - Connects to EC2. + - Writes `.env` from `EC2_ENV`. + - Runs `docker compose up -d --build`. + - Hits the health check URL (by default `http://localhost:3000/api/health`). +- If the health check fails, the workflow: + - Resets the repo on EC2 to the previous commit. + - Re-runs `docker compose up -d --build`. + +This gives you a sharp deployment with an automatic rollback path. + +--- + +## License + +Private / All rights reserved unless explicitly stated otherwise. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c56ca17 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + db: + # Use a Postgres image with pgvector preinstalled so we can enable the `vector` extension + image: pgvector/pgvector:pg17 + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-dropshipping} + ports: + - "5444:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-dropshipping}?schema=public + NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} + NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000} + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET} + depends_on: + - db + +volumes: + postgres_data: diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..2b9ca6b --- /dev/null +++ b/next.config.ts @@ -0,0 +1,30 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "*.shopify.com", + }, + { + protocol: "https", + hostname: "cdn.shopify.com", + }, + { + protocol: "https", + hostname: "*.myshopify.com", + }, + ], + }, + // Allow ngrok origin for development (fixes cross-origin warnings) + allowedDevOrigins: [ + "https://precontributive-tribally-dione.ngrok-free.dev", + "https://*.ngrok-free.dev", + "https://*.ngrok.io", + ], + reactCompiler: true, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..591a131 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8042 @@ +{ + "name": "pdc-poc", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pdc-poc", + "version": "0.1.0", + "dependencies": { + "@auth/core": "^0.41.0", + "@auth/prisma-adapter": "^2.11.1", + "@prisma/adapter-pg": "^7.3.0", + "@prisma/client": "^7.3.0", + "@types/nodemailer": "^7.0.9", + "@types/qrcode": "^1.5.6", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.34.3", + "bcryptjs": "^3.0.3", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dotenv": "^17.2.4", + "framer-motion": "^12.33.0", + "lucide-react": "^0.563.0", + "next": "16.1.6", + "next-auth": "^5.0.0-beta.30", + "nodemailer": "^7.0.7", + "otplib": "^13.2.1", + "pg": "^8.18.0", + "prisma": "^7.3.0", + "qrcode": "^1.5.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hot-toast": "^2.6.0", + "recharts": "^3.7.0", + "tailwind-merge": "^3.4.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", + "@types/node": "^20", + "@types/pg": "^8.16.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@auth/core": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", + "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", + "integrity": "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==", + "dependencies": { + "@auth/core": "0.41.1" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "devOptional": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "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", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", + "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.6.tgz", + "integrity": "sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==", + "dev": true, + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz", + "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz", + "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz", + "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz", + "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz", + "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz", + "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz", + "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz", + "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@otplib/core": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.2.1.tgz", + "integrity": "sha512-IyfHvYNCyipDxhmJdcUUvUeT3Hz84/GgM6G2G6BTEmnAKPzNA7U0kYGkxKZWY9h23W94RJk4qiClJRJN5zKGvg==" + }, + "node_modules/@otplib/hotp": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.2.1.tgz", + "integrity": "sha512-iRKqvj0TnemtXXtEswzBX50Z0yMNa0lH9PSdr5N4CJc1mDEuUmFFZQqnu3PfA3fPd3WeAU+mHgmK/xq18+K1QA==", + "dependencies": { + "@otplib/core": "13.2.1", + "@otplib/uri": "13.2.1" + } + }, + "node_modules/@otplib/plugin-base32-scure": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.2.1.tgz", + "integrity": "sha512-vnA2qqgJ/FbFbDNGOLAS8dKfCsJFXwFsZKYklE8yl2INkCOUR0vbVdJ2TVmufzC8R1RRZHW+cDR20ACgc9XFYg==", + "dependencies": { + "@otplib/core": "13.2.1", + "@scure/base": "^2.0.0" + } + }, + "node_modules/@otplib/plugin-crypto-noble": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.2.1.tgz", + "integrity": "sha512-Dxjmt4L+5eDWJf5EvbcMp+fxcliyKoB9N9sNQq/vuVAUvq+KiqpiiCQZ/wHyrN0ArB0NdevtK1KByyAq080ldg==", + "dependencies": { + "@noble/hashes": "^2.0.1", + "@otplib/core": "13.2.1" + } + }, + "node_modules/@otplib/totp": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.2.1.tgz", + "integrity": "sha512-LzDzAAK3w8rspF3urBnWjOlxso1SCGxX9Pnu/iy+HkC0y0HgiLsW7jhkr2hJ3u4cyBdL/tOKUhhELwsjyvunwQ==", + "dependencies": { + "@otplib/core": "13.2.1", + "@otplib/hotp": "13.2.1", + "@otplib/uri": "13.2.1" + } + }, + "node_modules/@otplib/uri": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.2.1.tgz", + "integrity": "sha512-ssYnfiUrFTs/rPRUW8h59m0MVLYOC+UKk7tVGYgtG15lLaLBrNBQjM2YFanuzn9Jm4iv9JxiNG7TRkwcnyR09A==", + "dependencies": { + "@otplib/core": "13.2.1" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.3.0.tgz", + "integrity": "sha512-iuYQMbIPO6i9O45Fv8TB7vWu00BXhCaNAShenqF7gLExGDbnGp5BfFB4yz1K59zQ59jF6tQ9YHrg0P6/J3OoLg==", + "dependencies": { + "@prisma/driver-adapter-utils": "7.3.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.3.0.tgz", + "integrity": "sha512-FXBIxirqQfdC6b6HnNgxGmU7ydCPEPk7maHMOduJJfnTP+MuOGa15X4omjR/zpPUUpm8ef/mEFQjJudOGkXFcQ==", + "dependencies": { + "@prisma/client-runtime-utils": "7.3.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.3.0.tgz", + "integrity": "sha512-dG/ceD9c+tnXATPk8G+USxxYM9E6UdMTnQeQ+1SZUDxTz7SgQcfxEqafqIQHcjdlcNK/pvmmLfSwAs3s2gYwUw==" + }, + "node_modules/@prisma/config": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.3.0.tgz", + "integrity": "sha512-QyMV67+eXF7uMtKxTEeQqNu/Be7iH+3iDZOQZW5ttfbSwBamCSdwPszA0dum+Wx27I7anYTPLmRmMORKViSW1A==", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.3.0.tgz", + "integrity": "sha512-yh/tHhraCzYkffsI1/3a7SHX8tpgbJu1NPnuxS4rEpJdWAUDHUH25F1EDo6PPzirpyLNkgPPZdhojQK804BGtg==" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.3.0.tgz", + "integrity": "sha512-Wdlezh1ck0Rq2dDINkfSkwbR53q53//Eo1vVqVLwtiZ0I6fuWDGNPxwq+SNAIHnsU+FD/m3aIJKevH3vF13U3w==", + "dependencies": { + "@prisma/debug": "7.3.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.3.0.tgz", + "integrity": "sha512-cWRQoPDXPtR6stOWuWFZf9pHdQ/o8/QNWn0m0zByxf5Kd946Q875XdEJ52pEsX88vOiXUmjuPG3euw82mwQNMg==", + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "7.3.0", + "@prisma/engines-version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", + "@prisma/fetch-engine": "7.3.0", + "@prisma/get-platform": "7.3.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735.tgz", + "integrity": "sha512-IH2va2ouUHihyiTTRW889LjKAl1CusZOvFfZxCDNpjSENt7g2ndFsK0vdIw/72v7+jCN6YgkHmdAP/BI7SDgyg==" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.3.0.tgz", + "integrity": "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==", + "dependencies": { + "@prisma/debug": "7.3.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.3.0.tgz", + "integrity": "sha512-Mm0F84JMqM9Vxk70pzfNpGJ1lE4hYjOeLMu7nOOD1i83nvp8MSAcFYBnHqLvEZiA6onUR+m8iYogtOY4oPO5lQ==", + "dependencies": { + "@prisma/debug": "7.3.0", + "@prisma/engines-version": "7.3.0-16.9d6ad21cbbceab97458517b147a6a09ff43aa735", + "@prisma/get-platform": "7.3.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.3.0.tgz", + "integrity": "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg==", + "dependencies": { + "@prisma/debug": "7.3.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz", + "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, + "node_modules/@scure/base": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.0.0.tgz", + "integrity": "sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bcryptjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz", + "integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==", + "deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "bcryptjs": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", + "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "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==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", + "integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/type-utils": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.54.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.54.0.tgz", + "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.54.0.tgz", + "integrity": "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==", + "dev": true, + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.54.0", + "@typescript-eslint/types": "^8.54.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz", + "integrity": "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz", + "integrity": "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz", + "integrity": "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.54.0.tgz", + "integrity": "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz", + "integrity": "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==", + "dev": true, + "dependencies": { + "@typescript-eslint/project-service": "8.54.0", + "@typescript-eslint/tsconfig-utils": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/visitor-keys": "8.54.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.54.0", + "@typescript-eslint/types": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz", + "integrity": "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.54.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.36.2", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.2.tgz", + "integrity": "sha512-C0Yt8hc12vLaQYRG1fMci8iPrLtnTdbJG0HR5T8vKnvEP/1RdMMblsOJs5/jp0JXZJ1oSzMnQz4J9EVezNpI6A==", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-react-compiler": { + "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, + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dotenv": { + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", + "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.6.tgz", + "integrity": "sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==", + "dev": true, + "dependencies": { + "@next/eslint-plugin-next": "16.1.6", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^7.0.0", + "globals": "16.4.0", + "typescript-eslint": "^8.46.0" + }, + "peerDependencies": { + "eslint": ">=9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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==", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/framer-motion": { + "version": "12.33.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.33.0.tgz", + "integrity": "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==", + "dependencies": { + "motion-dom": "^12.33.0", + "motion-utils": "^12.29.2", + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==" + }, + "node_modules/graphmatch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.0.tgz", + "integrity": "sha512-0E62MaTW5rPZVRLyIJZG/YejmdA/Xr1QydHEw3Vt+qOKkMIOE8WDLc9ZX2bmAjtJFZcId4lEdrdmASsEy7D1QA==" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/lucide-react": { + "version": "0.563.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz", + "integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/motion-dom": { + "version": "12.33.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.33.0.tgz", + "integrity": "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==" + }, + "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==", + "dev": true + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/next": { + "version": "16.1.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", + "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", + "dependencies": { + "@next/env": "16.1.6", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.6", + "@next/swc-darwin-x64": "16.1.6", + "@next/swc-linux-arm64-gnu": "16.1.6", + "@next/swc-linux-arm64-musl": "16.1.6", + "@next/swc-linux-x64-gnu": "16.1.6", + "@next/swc-linux-x64-musl": "16.1.6", + "@next/swc-win32-arm64-msvc": "16.1.6", + "@next/swc-win32-x64-msvc": "16.1.6", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "5.0.0-beta.30", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.30.tgz", + "integrity": "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==", + "dependencies": { + "@auth/core": "0.41.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", + "nodemailer": "^7.0.7", + "react": "^18.2.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/@auth/core": { + "version": "0.41.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.0.tgz", + "integrity": "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.0.tgz", + "integrity": "sha512-8csy5IBFI2ex2hTVpaHN2j+LNE199AgiI7y4dMintrr8i0lQiFn+0AWMZrWdHKIgMOer65f8IThysYhoReqjWA==" + }, + "node_modules/oauth4webapi": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.4.tgz", + "integrity": "sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/otplib": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-13.2.1.tgz", + "integrity": "sha512-Cft9h/m34LtvnoB2TjP1E1E6v0biwcUntl6U4e+HgWrTa0bpwmb+u/D9gLFA+U6/ztlvrult0811Bu30nUVUuA==", + "dependencies": { + "@otplib/core": "13.2.1", + "@otplib/hotp": "13.2.1", + "@otplib/plugin-base32-scure": "13.2.1", + "@otplib/plugin-crypto-noble": "13.2.1", + "@otplib/totp": "13.2.1", + "@otplib/uri": "13.2.1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "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==" + }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prisma": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.3.0.tgz", + "integrity": "sha512-ApYSOLHfMN8WftJA+vL6XwAPOh/aZ0BgUyyKPwUFgjARmG6EBI9LzDPf6SWULQMSAxydV9qn5gLj037nPNlg2w==", + "hasInstallScript": true, + "dependencies": { + "@prisma/config": "7.3.0", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.3.0", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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==", + "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/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==" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "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", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.54.0.tgz", + "integrity": "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.54.0", + "@typescript-eslint/parser": "8.54.0", + "@typescript-eslint/typescript-estree": "8.54.0", + "@typescript-eslint/utils": "8.54.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1a16d57 --- /dev/null +++ b/package.json @@ -0,0 +1,53 @@ +{ + "name": "pdc-poc", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@auth/core": "^0.41.0", + "@auth/prisma-adapter": "^2.11.1", + "@prisma/adapter-pg": "^7.3.0", + "@prisma/client": "^7.3.0", + "@types/nodemailer": "^7.0.9", + "@types/qrcode": "^1.5.6", + "@upstash/ratelimit": "^2.0.5", + "@upstash/redis": "^1.34.3", + "bcryptjs": "^3.0.3", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "dotenv": "^17.2.4", + "framer-motion": "^12.33.0", + "lucide-react": "^0.563.0", + "next": "16.1.6", + "next-auth": "^5.0.0-beta.30", + "nodemailer": "^7.0.7", + "otplib": "^13.2.1", + "pg": "^8.18.0", + "prisma": "^7.3.0", + "qrcode": "^1.5.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hot-toast": "^2.6.0", + "recharts": "^3.7.0", + "tailwind-merge": "^3.4.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^3.0.0", + "@types/node": "^20", + "@types/pg": "^8.16.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260209_init_pgvector/steps.sql b/prisma/migrations/20260209_init_pgvector/steps.sql new file mode 100644 index 0000000..763c390 --- /dev/null +++ b/prisma/migrations/20260209_init_pgvector/steps.sql @@ -0,0 +1,15 @@ +-- Enable pgvector extension and create index for knowledge chunk embeddings + +CREATE EXTENSION IF NOT EXISTS vector; + +-- Adjust the embedding column on KnowledgeChunk to use a concrete vector dimension. +-- NOTE: Update 1536 to match the actual embedding size used by your Gemini model. +ALTER TABLE "KnowledgeChunk" + ALTER COLUMN "embedding" TYPE vector(1536); + +CREATE INDEX IF NOT EXISTS "KnowledgeChunk_embedding_storeId_idx" + ON "KnowledgeChunk" + USING ivfflat ("embedding" vector_cosine_ops) + WITH (lists = 100) + WHERE "storeId" IS NOT NULL; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..1a8a46c --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,463 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +enum UserRole { + VENDOR + SUPPLIER + ADMIN +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + password String? + role UserRole @default(VENDOR) + + activeStoreId String? // Track which store is currently active + + // MFA (Multi-Factor Authentication) + mfaEnabled Boolean @default(false) + mfaSecret String? // Encrypted TOTP secret + + // Relations + accounts Account[] + sessions Session[] + stores Store[] // Changed from singular to support multiple stores + supplierProfile SupplierProfile? + notifications Notification[] + gmailIntegration GmailIntegration? + emailLogs EmailLog[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Auth.js / NextAuth Prisma adapter models (required for OAuth e.g. Google) +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? @db.Text + access_token String? @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? @db.Text + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model VerificationToken { + identifier String + token String + expires DateTime + + @@unique([identifier, token]) +} + +model Store { + id String @id @default(cuid()) + userId String // Removed @unique to allow multiple stores per user + user User @relation(fields: [userId], references: [id]) + + businessName String + email String + timezone String + country String + + // Shopify Credentials + shopifyStoreName String + shopifyDomain String + shopifyApiKey String + shopifyApiSecret String + shopifyAccessToken String + + onboardingComplete Boolean @default(false) + isActive Boolean @default(true) // Track if this is the active store + + // Relations + connections Connection[] + cachedProducts CachedProduct[] + cachedOrders CachedOrder[] + cachedCustomers CachedCustomer[] + syncMetadata SyncMetadata[] + invitations Invitation[] + productSyncs ProductSync[] + knowledgeDocuments KnowledgeDocument[] + faqItems FaqItem[] + knowledgeChunks KnowledgeChunk[] + emailLogs EmailLog[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) // Add index for performance +} + +model CachedProduct { + id String @id @default(cuid()) + shopifyProductId String + storeId String + title String + vendor String? + productType String? + status String? + variants Json // Store variants as JSON + images Json // Store images as JSON + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime @default(now()) + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([shopifyProductId, storeId]) + @@index([storeId]) +} + +model CachedOrder { + id String @id @default(cuid()) + shopifyOrderId String + storeId String + orderNumber String + email String? + totalPrice String + currency String? + financialStatus String? + fulfillmentStatus String? + lineItems Json // Store line items as JSON + customer Json? // Store customer info as JSON + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime @default(now()) + shopifyCreatedAt DateTime? + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([shopifyOrderId, storeId]) + @@index([storeId]) + @@index([shopifyCreatedAt]) +} + +model CachedCustomer { + id String @id @default(cuid()) + shopifyCustomerId String + storeId String + email String? + firstName String? + lastName String? + ordersCount Int @default(0) + totalSpent String? + phone String? + tags String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSyncedAt DateTime @default(now()) + shopifyCreatedAt DateTime? + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([shopifyCustomerId, storeId]) + @@index([storeId]) +} + +model SyncMetadata { + id String @id @default(cuid()) + storeId String + resourceType String // "products", "orders", "customers" + lastSyncAt DateTime @default(now()) + syncStatus String @default("success") // "success", "failed", "in_progress" + recordCount Int @default(0) + errorMessage String? + + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + @@unique([storeId, resourceType]) + @@index([storeId]) +} + +model SupplierProfile { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id]) + + companyName String + description String? + + // Relations + connections Connection[] + products Product[] + invitations Invitation[] + productSyncs ProductSync[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Connection { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id]) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + + status ConnectionStatus @default(PENDING) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([storeId, supplierId]) +} + +model Invitation { + id String @id @default(cuid()) + token String @unique + storeId String + store Store @relation(fields: [storeId], references: [id]) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + + email String // Supplier email to send invitation to + expiresAt DateTime + used Boolean @default(false) + + createdAt DateTime @default(now()) + usedAt DateTime? + + @@index([token]) + @@index([supplierId]) + @@index([storeId]) +} + +enum ConnectionStatus { + PENDING + CONNECTED + REJECTED +} + +model Product { + id String @id @default(cuid()) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + + title String + description String? + price Float + sku String? + imageUrl String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +enum ProductSyncStatus { + PENDING + ACCEPTED + REJECTED +} + +model ProductSync { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id]) + supplierId String + supplier SupplierProfile @relation(fields: [supplierId], references: [id]) + + shopifyProductId String // Shopify product ID from vendor's store + shopifyProductTitle String + shopifyProductData Json? // Store full product data for reference + + status ProductSyncStatus @default(PENDING) + + // Metadata + requestedBy String // userId of vendor who requested + acceptedAt DateTime? + rejectedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([storeId, supplierId, shopifyProductId]) + @@index([storeId]) + @@index([supplierId]) + @@index([status]) + @@index([shopifyProductId]) +} + +model GmailIntegration { + id String @id @default(cuid()) + userId String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + accessToken String @db.Text + refreshToken String? @db.Text + expiresAt DateTime + + email String // Gmail email address + scope String // OAuth scopes granted + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +enum NotificationType { + INVITATION_ACCEPTED + INVITATION_RECEIVED + CONNECTION_STATUS_CHANGED + ORDER_RECEIVED + PRODUCT_UPDATED + PRODUCT_SYNC_REQUESTED + PRODUCT_SYNC_ACCEPTED + PRODUCT_SYNC_REJECTED +} + +model Notification { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type NotificationType + title String + message String + read Boolean @default(false) + readAt DateTime? + + // Optional metadata for linking to related entities + metadata Json? // Store related IDs (connectionId, invitationId, etc.) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([userId, read]) + @@index([createdAt]) +} + +model EmailLog { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + storeId String? + store Store? @relation(fields: [storeId], references: [id]) + + to String + subject String + bodyPreview String? + + // Whether this email was triggered automatically by the system + isAutomated Boolean @default(false) + trigger String? // e.g. "GMAIL_AUTO_REPLY", "TICKET_CREATED" + + status String // "SENT" | "FAILED" + providerMessageId String? + errorMessage String? + + sentAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([storeId, sentAt]) + @@index([userId, sentAt]) +} + +enum KnowledgeDocumentStatus { + UPLOADED + PROCESSING + READY + FAILED +} + +enum KnowledgeChunkSourceType { + DOCUMENT + FAQ +} + +model KnowledgeDocument { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + title String + fileName String + mimeType String + sizeBytes Int + status KnowledgeDocumentStatus @default(UPLOADED) + errorMessage String? + + // Where the raw file is stored (local path or object storage key) + storagePath String + + // Relations + chunks KnowledgeChunk[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId]) +} + +model FaqItem { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + question String + answer String + tags Json? + + // Relations + chunks KnowledgeChunk[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId]) +} + +model KnowledgeChunk { + id String @id @default(cuid()) + storeId String + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + documentId String? + document KnowledgeDocument? @relation(fields: [documentId], references: [id]) + + faqItemId String? + faqItem FaqItem? @relation(fields: [faqItemId], references: [id]) + + sourceType KnowledgeChunkSourceType + content String + metadata Json? + + // pgvector-backed column; accessed via raw SQL for similarity search + embedding Unsupported("vector") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([storeId]) + @@index([documentId]) + @@index([faqItemId]) +} diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..80da7cd --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/question1/.env b/question1/.env deleted file mode 100644 index 0e63f15..0000000 --- a/question1/.env +++ /dev/null @@ -1 +0,0 @@ -TASK_TO_RUN=edit_distance diff --git a/question1/Dockerfile b/question1/Dockerfile deleted file mode 100644 index dfeeffb..0000000 --- a/question1/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM debian:stable-slim - -# Install necessary packages -RUN apt-get update && apt-get install -y --no-install-recommends \ - g++ \ - make \ - openmpi-bin \ - libopenmpi-dev \ - openssh-server && \ - rm -rf /var/lib/apt/lists/* - -# Set up SSH for MPI -RUN mkdir -p /var/run/sshd -RUN ssh-keygen -t rsa -f /root/.ssh/id_rsa -N '' -RUN cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys -RUN chmod 600 /root/.ssh/authorized_keys -RUN echo "Host *\n\tStrictHostKeyChecking no" >> /root/.ssh/config -RUN sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config - -# Set working directory -WORKDIR /app - -# Copy source files -COPY . /app/ - -# Compile the MPI program -RUN mpic++ -O2 -std=c++17 -o edit_distance edit_distance_mpi.cpp -RUN chmod +x /app/edit_distance || true diff --git a/question1/README.md b/question1/README.md deleted file mode 100644 index 3ed0c42..0000000 --- a/question1/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# MPI Edit Distance Implementation - -This project implements the **Edit Distance (Needleman-Wunsch)** algorithm using MPI with three different communication strategies: - -- **Point-to-Point Communication** (MPI_Send/MPI_Recv) -- **Scatter + Gather Communication** (MPI_Scatter/Gather) -- **Gather-based Multiple Pair Computation** (MPI_Gather) - -## Quick Start - -### Option 1: Single Container (Recommended - Works Perfectly) - -```bash -# Build the project -docker compose build - -# Method 1: Use default sequences (ACGTAG vs ACTG) -docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 2 --allow-run-as-root ./edit_distance" - -# Method 2: Provide custom sequences as command line arguments -docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 2 --allow-run-as-root ./edit_distance HELLO WORLD" - -# Method 3: Interactive input (single process only) -docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 1 --allow-run-as-root ./edit_distance -i" - -# Run with different process counts -docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 4 --allow-run-as-root ./edit_distance" -docker run --rm -it pdc-poc-master bash -c "cd /app && mpirun -np 8 --allow-run-as-root ./edit_distance" -``` - -### Option 2: Multi-Container Setup - -```bash -# Build the project -docker compose build - -# Run with 2 processes (1 master + 1 worker) -docker compose up --scale worker1=1 worker2=0 worker3=0 worker4=0 - -# Run with 4 processes (1 master + 3 workers) -docker compose up --scale worker1=1 worker2=1 worker3=1 worker4=0 - -# Run with 5 processes (1 master + 4 workers) -docker compose up --scale worker1=1 worker2=1 worker3=1 worker4=1 - -# View logs to see results -docker compose logs - -# Shut down containers -docker compose down -``` - -## Expected Output - -``` -Sequence 1: ACGTAG -Sequence 2: ACTG - -Edit Distance (P2P) = 2, Time = 0.000s -Edit Distance (Scatter) = 2, Time = 0.000s -Edit Distance (Gather) = [2, 3, 3], Time = 0.000s -``` - -## Input Methods - -1. **Default Sequences**: No arguments - uses "ACGTAG" vs "ACTG" -2. **Command Line**: `./edit_distance SEQUENCE1 SEQUENCE2` -3. **Interactive**: `./edit_distance -i` (single process only) - -## Troubleshooting - -- **SSH errors**: Use Option 1 (single container) for reliable execution -- **Permission errors**: Ensure Docker has proper permissions -- **Build errors**: Check that all files are in the correct directory - -## Files - -- `edit_distance_mpi.cpp` - Main MPI implementation -- `Dockerfile` - Container configuration -- `docker-compose.yml` - Multi-container orchestration -- `hostfile` - MPI host configuration -- `README.md` - This file diff --git a/question1/docker-compose.yml b/question1/docker-compose.yml deleted file mode 100644 index af584ae..0000000 --- a/question1/docker-compose.yml +++ /dev/null @@ -1,60 +0,0 @@ -version: '3.8' - -services: - master: - build: . - container_name: pdc-master - hostname: master - volumes: - - ./hostfile:/app/hostfile - environment: - - FRIENDLY_NAME=pdc-master - # This line automatically reads from the .env file - command: > - sh -c "/usr/sbin/sshd & sleep 5 && mpirun -np 5 --hostfile /app/hostfile --allow-run-as-root /app/${TASK_TO_RUN:-edit_distance}" - networks: - - pdcn - - worker1: - build: . - container_name: pdc-worker1 - hostname: worker1 - environment: - - FRIENDLY_NAME=pdc-worker1 - command: /usr/sbin/sshd -D - networks: - - pdcn - - worker2: - build: . - container_name: pdc-worker2 - hostname: worker2 - environment: - - FRIENDLY_NAME=pdc-worker2 - command: /usr/sbin/sshd -D - networks: - - pdcn - - worker3: - build: . - container_name: pdc-worker3 - hostname: worker3 - environment: - - FRIENDLY_NAME=pdc-worker3 - command: /usr/sbin/sshd -D - networks: - - pdcn - - worker4: - build: . - container_name: pdc-worker4 - hostname: worker4 - environment: - - FRIENDLY_NAME=pdc-worker4 - command: /usr/sbin/sshd -D - networks: - - pdcn - -networks: - pdcn: - driver: bridge diff --git a/question1/edit_distance_mpi.cpp b/question1/edit_distance_mpi.cpp deleted file mode 100644 index a879af6..0000000 --- a/question1/edit_distance_mpi.cpp +++ /dev/null @@ -1,230 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace std; - -int min3(int a, int b, int c) { - return min(a, min(b, c)); -} - -int compute_edit_distance(const string& seq1, const string& seq2) { - int m = seq1.length(); - int n = seq2.length(); - - vector> dp(m + 1, vector(n + 1)); - - // Initialize base cases - for (int i = 0; i <= m; i++) { - dp[i][0] = i; - } - for (int j = 0; j <= n; j++) { - dp[0][j] = j; - } - - // Fill the DP table - for (int i = 1; i <= m; i++) { - for (int j = 1; j <= n; j++) { - if (seq1[i-1] == seq2[j-1]) { - dp[i][j] = dp[i-1][j-1]; - } else { - dp[i][j] = min3( - dp[i-1][j] + 1, // deletion - dp[i][j-1] + 1, // insertion - dp[i-1][j-1] + 1 // substitution - ); - } - } - } - - return dp[m][n]; -} - -int p2p_edit_distance(const string& seq1, const string& seq2, int rank, int size) { - double start_time, end_time; - - if (rank == 0) { - start_time = MPI_Wtime(); - } - - int result_p2p; - - if (size == 1) { - result_p2p = compute_edit_distance(seq1, seq2); - } else { - // Simplified P2P: Each process computes independently, then gather results - result_p2p = compute_edit_distance(seq1, seq2); - - // Gather results using point-to-point communication - if (rank == 0) { - vector all_results(size); - all_results[0] = result_p2p; - - // Receive results from other processes - for (int proc = 1; proc < size; proc++) { - MPI_Recv(&all_results[proc], 1, MPI_INT, proc, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); - } - - result_p2p = all_results[0]; // Use first result - } else { - // Send result to process 0 - MPI_Send(&result_p2p, 1, MPI_INT, 0, 0, MPI_COMM_WORLD); - } - } - - if (rank == 0) { - end_time = MPI_Wtime(); - cout << "Edit Distance (P2P) = " << result_p2p << ", Time = " << fixed << setprecision(6) << (end_time - start_time) << "s" << endl; - } - - return result_p2p; -} - -int scatter_gather_edit_distance(const string& seq1, const string& seq2, int rank, int size) { - double start_time, end_time; - - if (rank == 0) { - start_time = MPI_Wtime(); - } - - int result_scatter; - - if (size == 1) { - result_scatter = compute_edit_distance(seq1, seq2); - } else { - // Each process computes the full matrix independently - result_scatter = compute_edit_distance(seq1, seq2); - - // Gather results (though they should be the same) - vector all_results(size); - MPI_Gather(&result_scatter, 1, MPI_INT, all_results.data(), 1, MPI_INT, 0, MPI_COMM_WORLD); - - if (rank == 0) { - result_scatter = all_results[0]; // Use first result - } - } - - if (rank == 0) { - end_time = MPI_Wtime(); - cout << "Edit Distance (Scatter) = " << result_scatter << ", Time = " << fixed << setprecision(6) << (end_time - start_time) << "s" << endl; - } - - return result_scatter; -} - -void gather_multiple_pairs(const vector>& sequence_pairs, int rank, int size, vector& results) { - double start_time, end_time; - - if (rank == 0) { - start_time = MPI_Wtime(); - } - - // Distribute pairs among processes - int pairs_per_proc = max(1, (int)sequence_pairs.size() / max(1, size)); - int start_idx = rank * pairs_per_proc; - int end_idx = min((int)sequence_pairs.size(), (rank + 1) * pairs_per_proc); - - vector local_results; - - // Compute assigned pairs - for (int i = start_idx; i < end_idx; i++) { - int dist = compute_edit_distance(sequence_pairs[i].first, sequence_pairs[i].second); - local_results.push_back(dist); - } - - // Gather results using MPI_Gatherv - vector all_results; - vector all_sizes(size); - vector all_displs(size); - - // First gather the sizes - int local_size = local_results.size(); - MPI_Gather(&local_size, 1, MPI_INT, all_sizes.data(), 1, MPI_INT, 0, MPI_COMM_WORLD); - - if (rank == 0) { - // Calculate displacements - int total_size = 0; - for (int i = 0; i < size; i++) { - all_displs[i] = total_size; - total_size += all_sizes[i]; - } - all_results.resize(total_size); - } - - // Gather the actual data - MPI_Gatherv(local_results.data(), local_size, MPI_INT, - all_results.data(), all_sizes.data(), all_displs.data(), MPI_INT, - 0, MPI_COMM_WORLD); - - if (rank == 0) { - results = all_results; - end_time = MPI_Wtime(); - cout << "Edit Distance (Gather) = ["; - for (size_t i = 0; i < results.size(); i++) { - cout << results[i]; - if (i < results.size() - 1) cout << ", "; - } - cout << "], Time = " << fixed << setprecision(6) << (end_time - start_time) << "s" << endl; - } -} - -int main(int argc, char** argv) { - MPI_Init(&argc, &argv); - int rank, size; - MPI_Comm_rank(MPI_COMM_WORLD, &rank); - MPI_Comm_size(MPI_COMM_WORLD, &size); - - string seq1, seq2; - - // Get input from command line arguments or use defaults - if (argc >= 3) { - seq1 = argv[1]; - seq2 = argv[2]; - } else if (argc == 2 && string(argv[1]) == "-i") { - // Interactive input mode (only for single process) - if (rank == 0 && size == 1) { - cout << "Enter Sequence 1: "; - getline(cin, seq1); - cout << "Enter Sequence 2: "; - getline(cin, seq2); - } - } else { - // Default sequences if no arguments provided - seq1 = "ACGTAG"; - seq2 = "ACTG"; - } - - vector> sequence_pairs = { - {seq1, seq2}, - {"CGTAG", "ATG"}, - {"AGTAG", "ACT"} - }; - - // Add longer sequences for better timing measurements - if (seq1.length() < 10) { - sequence_pairs.push_back({"ACGTAGCTAGCTAGCTAG", "ACTGCTAGCTAGCTAG"}); - sequence_pairs.push_back({"HELLOWORLD", "WORLDHELLO"}); - } - - // Display input sequences - if (rank == 0) { - cout << "Sequence 1: " << seq1 << endl; - cout << "Sequence 2: " << seq2 << endl; - cout << endl; - } - - // Run all three scenarios - p2p_edit_distance(seq1, seq2, rank, size); - scatter_gather_edit_distance(seq1, seq2, rank, size); - - vector gather_results; - gather_multiple_pairs(sequence_pairs, rank, size, gather_results); - - MPI_Finalize(); - return 0; -} diff --git a/question1/hostfile b/question1/hostfile deleted file mode 100644 index 4ab67f8..0000000 --- a/question1/hostfile +++ /dev/null @@ -1,5 +0,0 @@ -master slots=1 -worker1 slots=1 -worker2 slots=1 -worker3 slots=1 -worker4 slots=1 diff --git a/question1/req.md b/question1/req.md deleted file mode 100644 index 8842eb5..0000000 --- a/question1/req.md +++ /dev/null @@ -1,59 +0,0 @@ -# Problem Statement: MPI Edit Distance Implementation - -## Objective -Implement the Edit Distance (Needleman-Wunsch global alignment) algorithm using MPI with three different communication strategies and compare their performance. - -## Requirements - -### 1. Algorithm Implementation -- Implement the Needleman-Wunsch algorithm for global sequence alignment -- Calculate edit distance between two DNA/protein sequences -- Use dynamic programming approach - -### 2. MPI Communication Strategies - -#### Strategy 1: Point-to-Point Communication -- Use `MPI_Send` and `MPI_Recv` for data distribution -- Distribute computation across multiple processes -- Collect results using point-to-point communication - -#### Strategy 2: Scatter + Gather Communication -- Use `MPI_Scatter` to distribute data -- Use `MPI_Gather` to collect results -- Implement collective communication patterns - -#### Strategy 3: Gather-based Multiple Pair Computation -- Process multiple sequence pairs in parallel -- Use `MPI_Gather` to collect results from all processes -- Implement load balancing across processes - -### 3. Input/Output Format - -**Input:** -``` -Sequence 1: ACGTAG -Sequence 2: ACTG -``` - -**Output:** -``` -Edit Distance (P2P) = 2, Time = 0.000s -Edit Distance (Scatter) = 2, Time = 0.000s -Edit Distance (Gather) = [2, 3, 3], Time = 0.000s -``` - -### 4. Performance Requirements -- Measure execution time for each strategy -- Support different numbers of MPI processes (2, 4, 8) -- Compare performance across communication strategies - -### 5. Docker Environment -- Containerize the application -- Support both single-container and multi-container execution -- Provide easy setup and execution instructions - -## Expected Results -- Correct edit distance calculations for all strategies -- Performance comparison showing time differences -- Scalable implementation that works with different process counts -- Clean, readable output matching the specified format diff --git a/scripts/fix-domains.ts b/scripts/fix-domains.ts new file mode 100644 index 0000000..0f765a4 --- /dev/null +++ b/scripts/fix-domains.ts @@ -0,0 +1,66 @@ +/** + * Script to fix domains with whitespace issues in the database + * Run with: npx tsx scripts/fix-domains.ts + * + * Make sure DATABASE_URL is set in your environment or .env file + */ + +import "dotenv/config" +import pg from "pg" +import { PrismaPg } from "@prisma/adapter-pg" +import { PrismaClient } from "@prisma/client" +import { PrismaClientOptions } from "@prisma/client/runtime/client" + +// Use the same setup as the main app +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }) +const adapter = new PrismaPg(pool) +const prisma = new PrismaClient({ + adapter: adapter as unknown as PrismaClientOptions["adapter"] +}) + +async function fixStoreDomains() { + try { + const stores = await prisma.store.findMany({ + select: { + id: true, + shopifyDomain: true, + }, + }) + + let fixed = 0 + for (const store of stores) { + if (!store.shopifyDomain) continue + + // Clean domain: remove protocol, trailing slashes, and trim whitespace + const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + // Only update if domain changed + if (cleanDomain !== store.shopifyDomain) { + await prisma.store.update({ + where: { id: store.id }, + data: { shopifyDomain: cleanDomain }, + }) + console.log(`Fixed domain for store ${store.id}: "${store.shopifyDomain}" -> "${cleanDomain}"`) + fixed++ + } + } + + console.log(`\n✅ Fixed ${fixed} store domain(s) out of ${stores.length} total`) + return { fixed, total: stores.length } + } catch (error) { + console.error("❌ Error fixing domains:", error) + throw error + } finally { + await prisma.$disconnect() + await pool.end() + } +} + +fixStoreDomains() + .then(() => { + process.exit(0) + }) + .catch((error) => { + console.error(error) + process.exit(1) + }) diff --git a/src/app/admin/connections/page.tsx b/src/app/admin/connections/page.tsx new file mode 100644 index 0000000..d248b85 --- /dev/null +++ b/src/app/admin/connections/page.tsx @@ -0,0 +1,138 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { Link2 } from "lucide-react" + +interface AdminConnection { + id: string + status: string + createdAt: string + updatedAt: string + store: { + businessName: string + shopifyStoreName: string + } + supplier: { + companyName: string + } +} + +async function getConnections(): Promise { + const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/connections`, { + cache: "no-store", + }) + if (!res.ok) return [] + const data = (await res.json()) as { connections: AdminConnection[] } + return data.connections +} + +export default async function AdminConnectionsPage() { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const connections = await getConnections() + + return ( +
+
+
+

+ Connections +

+

+ Read-only view of vendor ↔ supplier connections across the platform. +

+
+
+ +
+ + + + + + + + + + + + {connections.length === 0 && ( + + + + )} + {connections.map((connection) => ( + + + + + + + + ))} + +
Vendor StoreSupplierStatusCreatedLast Updated
+ No connections found. +
+
+
+ +
+
+
+ {connection.store.businessName} +
+
+ {connection.store.shopifyStoreName} +
+
+
+
+
+ {connection.supplier.companyName} +
+
+ + + {new Date(connection.createdAt).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} + + {new Date(connection.updatedAt).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} +
+
+
+ ) +} + +function StatusPill({ status }: { status: string }) { + const normalized = status.toUpperCase() + let color = + "bg-slate-800/60 text-slate-200 border-slate-500/40" + + if (normalized === "CONNECTED") { + color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40" + } else if (normalized === "PENDING") { + color = "bg-amber-500/15 text-amber-200 border-amber-500/40" + } else if (normalized === "REJECTED") { + color = "bg-rose-500/15 text-rose-200 border-rose-500/40" + } + + return ( + + {normalized} + + ) +} + diff --git a/src/app/admin/emails/page.tsx b/src/app/admin/emails/page.tsx new file mode 100644 index 0000000..99521a7 --- /dev/null +++ b/src/app/admin/emails/page.tsx @@ -0,0 +1,204 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { Mail } from "lucide-react" + +interface AdminEmailLog { + id: string + to: string + subject: string + bodyPreview: string | null + isAutomated: boolean + trigger: string | null + status: string + providerMessageId: string | null + errorMessage: string | null + sentAt: string + store: { + businessName: string + shopifyStoreName: string + } | null +} + +async function getEmails(): Promise<{ emails: AdminEmailLog[]; total: number }> { + // Use a relative URL so the admin's session cookies are forwarded + const res = await fetch("/api/admin/emails?limit=100", { + cache: "no-store", + }) + if (!res.ok) { + return { emails: [], total: 0 } + } + const data = (await res.json()) as { emails: AdminEmailLog[]; total: number } + return data +} + +export default async function AdminEmailsPage() { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const { emails, total } = await getEmails() + const failedCount = emails.filter((e) => e.status === "FAILED").length + const automatedCount = emails.filter((e) => e.isAutomated).length + + return ( +
+
+
+

+ Automated Emails +

+

+ Read-only visibility into automated and system-triggered emails sent + from vendor Gmail integrations. +

+
+
+ +
+
+
+

+ Total Logged Emails +

+

{total}

+

+ Across all vendors and stores +

+
+
+ +
+
+
+
+

+ Automated Emails +

+

{automatedCount}

+

+ Marked as system-triggered +

+
+
+ +
+
+
+
+

+ Failed Sends +

+

{failedCount}

+

+ Emails that failed to send via Gmail API +

+
+
+ +
+
+
+ +
+ + + + + + + + + + + + + {emails.length === 0 && ( + + + + )} + {emails.map((email) => ( + + + + + + + + + ))} + +
Vendor StoreRecipientSubjectTriggerStatusSent At
+ No emails logged yet. +
+ {email.store ? ( +
+
+ {email.store.businessName} +
+
+ {email.store.shopifyStoreName} +
+
+ ) : ( + + Unscoped / System + + )} +
+ {email.to} + +
+ {email.subject} +
+ {email.bodyPreview && ( +
+ {email.bodyPreview} +
+ )} +
+ {email.isAutomated + ? email.trigger || "AUTOMATED" + : email.trigger || "MANUAL"} + + + {email.errorMessage && ( +
+ {email.errorMessage} +
+ )} +
+ {new Date(email.sentAt).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} +
+
+
+ ) +} + +function StatusPill({ status }: { status: string }) { + const normalized = status.toUpperCase() + let color = + "bg-slate-800/60 text-slate-200 border-slate-500/40" + + if (normalized === "SENT") { + color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40" + } else if (normalized === "FAILED") { + color = "bg-rose-500/15 text-rose-200 border-rose-500/40" + } + + return ( + + {normalized} + + ) +} + diff --git a/src/app/admin/events/page.tsx b/src/app/admin/events/page.tsx new file mode 100644 index 0000000..315481f --- /dev/null +++ b/src/app/admin/events/page.tsx @@ -0,0 +1,252 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { Activity, Link2, Radar, Shuffle } from "lucide-react" + +type AdminEventType = "CONNECTION" | "PRODUCT_SYNC" | "SYNC_HEALTH" + +interface AdminEvent { + id: string + type: AdminEventType + status: string + timestamp: string + storeName: string | null + supplierName: string | null + metadata?: Record +} + +async function getEvents(type?: AdminEventType): Promise { + const searchParams = new URLSearchParams() + searchParams.set("limit", "50") + if (type) { + searchParams.set("type", type) + } + + const res = await fetch( + `${process.env.NEXTAUTH_URL}/api/admin/events?${searchParams.toString()}`, + { + cache: "no-store", + }, + ) + + if (!res.ok) { + return [] + } + + const data = (await res.json()) as { events: AdminEvent[] } + return data.events +} + +function typeBadge(eventType: AdminEventType) { + switch (eventType) { + case "CONNECTION": + return ( + + + Connection + + ) + case "PRODUCT_SYNC": + return ( + + + Product Sync + + ) + case "SYNC_HEALTH": + return ( + + + Sync Health + + ) + default: + return null + } +} + +function statusBadge(status: string) { + const normalized = status.toUpperCase() + let colorClasses = + "bg-slate-800/60 text-slate-200 border-slate-500/40" + + if (normalized === "CONNECTED" || normalized === "ACCEPTED" || normalized === "SUCCESS") { + colorClasses = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40" + } else if (normalized === "PENDING" || normalized === "IN_PROGRESS") { + colorClasses = "bg-amber-500/15 text-amber-200 border-amber-500/40" + } else if (normalized === "REJECTED" || normalized === "FAILED") { + colorClasses = "bg-rose-500/15 text-rose-200 border-rose-500/40" + } + + return ( + + {normalized} + + ) +} + +export default async function AdminEventsPage({ + searchParams, +}: { + searchParams: Promise<{ type?: string }> +}) { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const { type } = await searchParams + const filterType = + type && ["CONNECTION", "PRODUCT_SYNC", "SYNC_HEALTH"].includes(type) + ? (type as AdminEventType) + : undefined + + const events = await getEvents(filterType) + + return ( +
+
+
+

+ Event Timeline +

+

+ Chronological, read-only view of platform activity across connections, + product syncs, and sync health events. +

+
+
+ +
+ + Filter by type: + +
+ + + + +
+
+ +
+ {events.length === 0 ? ( +
+

+ No events found for the current filter. +

+
+ ) : ( +
+
+
+ {events.map((event) => ( +
+
+
+
+
+ {typeBadge(event.type)} + {statusBadge(event.status)} +
+
+ + + {new Date( + event.timestamp, + ).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} + +
+
+
+ {event.storeName && ( + + {event.storeName} + + )} + {event.storeName && event.supplierName && ( + ↔ + )} + {event.supplierName && ( + + {event.supplierName} + + )} + {!event.storeName && !event.supplierName && ( + + Platform-level event + + )} +
+ {event.metadata && event.type === "PRODUCT_SYNC" && ( +

+ {(event.metadata.productTitle as string) || + "Product sync"} +

+ )} + {event.metadata && event.type === "SYNC_HEALTH" && ( +

+ {String( + (event.metadata.resourceType || + "unknown") as string, + ).toUpperCase()}{" "} + · Records:{" "} + {String( + (event.metadata.recordCount || + 0) as number, + )} +

+ )} +
+
+ ))} +
+
+ )} +
+
+ ) +} + +function FilterChip({ + label, + href, + active, +}: { + label: string + href: string + active: boolean +}) { + return ( + + {label} + + ) +} + diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..9320bd3 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,76 @@ +import { AdminSidebar } from "@/components/AdminSidebar" +import { MobileSidebar } from "@/components/MobileSidebar" +import UserAccountMenu from "@/components/UserAccountMenu" +import NotificationBell from "@/components/NotificationBell" +import PageTransition from "@/components/PageTransition" +import { auth } from "@/auth" +import { redirect } from "next/navigation" + +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const session = await auth() + + if (!session) { + redirect("/login") + } + + if (session.user.role !== "ADMIN") { + // Redirect vendors and suppliers back to their respective dashboards + if (session.user.role === "VENDOR") { + redirect("/dashboard") + } + if (session.user.role === "SUPPLIER") { + redirect("/supplier") + } + redirect("/login") + } + + return ( +
+ {/* Desktop Sidebar */} +
+ +
+ + {/* Main Content Area */} +
+ {/* Fixed Header */} +
+
+ {/* Reuse vendor mobile sidebar trigger for consistency */} + +
+

+ Admin Portal +

+ + Read-only observability for Shopify, vendors & suppliers + +
+
+
+ +
+ {session.user?.name || "Admin User"} + {session.user?.email} +
+ +
+
+ + {/* Content */} +
+ +
+ {children} +
+
+
+
+
+ ) +} + diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx new file mode 100644 index 0000000..d14df33 --- /dev/null +++ b/src/app/admin/orders/page.tsx @@ -0,0 +1,125 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { ShoppingCart } from "lucide-react" + +interface AdminOrder { + id: string + orderNumber: string + email: string | null + totalPrice: string + currency: string | null + financialStatus: string | null + fulfillmentStatus: string | null + createdAt: string + store: { + businessName: string + shopifyStoreName: string + } +} + +async function getOrders(): Promise { + const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/orders`, { + cache: "no-store", + }) + if (!res.ok) return [] + const data = (await res.json()) as { orders: AdminOrder[] } + return data.orders +} + +export default async function AdminOrdersPage() { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const orders = await getOrders() + + return ( +
+
+
+

+ Orders with Synced Products +

+

+ Orders from vendor stores that contain products synced with suppliers. +

+
+
+ +
+ + + + + + + + + + + + + + {orders.length === 0 && ( + + + + )} + {orders.map((order) => ( + + + + + + + + + + ))} + +
OrderVendor StoreCustomerTotalFinancialFulfillmentCreated
+ No orders with synced products found. +
+
+
+ +
+
+
+ #{order.orderNumber} +
+
+ Order ID: {order.id} +
+
+
+
+
+ {order.store.businessName} +
+
+ {order.store.shopifyStoreName} +
+
+ {order.email || "—"} + + {order.totalPrice} {order.currency || ""} + + {order.financialStatus || "—"} + + {order.fulfillmentStatus || "—"} + + {new Date(order.createdAt).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} +
+
+
+ ) +} + diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..d3a46ef --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,386 @@ +import { prisma } from "@/lib/prisma" +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { + Activity, + Link2, + Shuffle, + ShoppingCart, + Store, + Users +} from "lucide-react" + +type ConnectionGroup = { + status: string + _count: { _all: number } +} + +type ProductSyncGroup = { + status: string + _count: { _all: number } +} + +type RecentSyncError = { + id: string + resourceType: string + lastSyncAt: Date + errorMessage: string | null + store: { + shopifyStoreName: string + } +} + +type RecentEvent = { + id: string + shopifyProductTitle: string + status: string + createdAt: Date + store: { + shopifyStoreName: string + } + supplier: { + companyName: string + } +} + +interface AdminOverviewResult { + storeCount: number + supplierCount: number + connectionStats: ConnectionGroup[] + productSyncStats: ProductSyncGroup[] + orderCount: number + recentSyncErrors: RecentSyncError[] + recentEvents: RecentEvent[] +} + +async function getAdminOverview(): Promise { + const [ + storeCount, + supplierCount, + connectionStats, + productSyncStats, + orderCount, + recentSyncErrors, + recentEvents, + ] = await Promise.all([ + prisma.store.count(), + prisma.supplierProfile.count(), + prisma.connection.groupBy({ + by: ["status"], + _count: { _all: true }, + }) as unknown as Promise, + // Use `as any` to be compatible with the current Prisma client setup + (prisma as any).productSync.groupBy({ + by: ["status"], + _count: { _all: true }, + }) as Promise, + prisma.cachedOrder.count(), + prisma.syncMetadata.findMany({ + where: { syncStatus: { in: ["failed"] } }, + orderBy: { lastSyncAt: "desc" }, + take: 5, + include: { store: true }, + }) as Promise, + (prisma as any).productSync.findMany({ + orderBy: { createdAt: "desc" }, + take: 8, + include: { + store: true, + supplier: true, + }, + }) as Promise, + ]) + + return { + storeCount, + supplierCount, + connectionStats, + productSyncStats, + orderCount, + recentSyncErrors, + recentEvents, + } +} + +export default async function AdminOverviewPage() { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const { + storeCount, + supplierCount, + connectionStats, + productSyncStats, + orderCount, + recentSyncErrors, + recentEvents, + } = await getAdminOverview() + + const connectedCount = + connectionStats.find((c: ConnectionGroup) => c.status === "CONNECTED")?._count._all ?? 0 + const pendingConnections = + connectionStats.find((c: ConnectionGroup) => c.status === "PENDING")?._count._all ?? 0 + const rejectedConnections = + connectionStats.find((c: ConnectionGroup) => c.status === "REJECTED")?._count._all ?? 0 + + const pendingSyncs = + productSyncStats.find((c: ProductSyncGroup) => c.status === "PENDING")?._count._all ?? 0 + const acceptedSyncs = + productSyncStats.find((c: ProductSyncGroup) => c.status === "ACCEPTED")?._count._all ?? 0 + const rejectedSyncs = + productSyncStats.find((c: ProductSyncGroup) => c.status === "REJECTED")?._count._all ?? 0 + + return ( +
+
+
+

+ Platform Overview +

+

+ High-level, read-only visibility into how data flows between Shopify + stores, vendors and suppliers across JUNOHUB. +

+
+
+ +
+
+
+

+ Vendors & Stores +

+

{storeCount}

+

+ Total Shopify stores connected +

+
+
+ +
+
+ +
+
+

+ Suppliers +

+

{supplierCount}

+

+ Active supplier profiles +

+
+
+ +
+
+ +
+
+

+ Orders Indexed +

+

{orderCount}

+

+ Cached Shopify orders across all stores +

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

+ Connections +

+

+ Vendor ↔ Supplier relationships +

+
+
+ +
+
+
+
+

+ Connected +

+

+ {connectedCount} +

+
+
+

+ Pending +

+

+ {pendingConnections} +

+
+
+

+ Rejected +

+

+ {rejectedConnections} +

+
+
+
+ +
+
+
+

+ Product Syncs +

+

+ Vendor product sharing with suppliers +

+
+
+ +
+
+
+
+

+ Pending +

+

+ {pendingSyncs} +

+
+
+

+ Accepted +

+

+ {acceptedSyncs} +

+
+
+

+ Rejected +

+

+ {rejectedSyncs} +

+
+
+
+ +
+
+
+

+ Sync Health +

+

+ Recent sync failures across all stores +

+
+
+ +
+
+
+ {recentSyncErrors.length === 0 && ( +

+ No recent sync failures detected. +

+ )} + {recentSyncErrors.map((sync: RecentSyncError) => ( +
+

+ {sync.resourceType.toUpperCase()} ·{" "} + {sync.store.shopifyStoreName} +

+

+ {sync.errorMessage || "Unknown error"} +

+

+ Last attempt:{" "} + {sync.lastSyncAt.toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} +

+
+ ))} +
+
+
+ +
+
+
+

+ Recent Events +

+

+ Latest product sync activity across the network +

+
+
+ +
+
+
+ {recentEvents.length === 0 && ( +

+ No recent product sync events. +

+ )} + {recentEvents.map((event: RecentEvent) => ( +
+
+ +
+
+

+ + {event.store.shopifyStoreName} + {" "} + ↔{" "} + + {event.supplier.companyName} + +

+

+ {event.shopifyProductTitle} +

+

+ Status:{" "} + + {event.status} + {" "} + ·{" "} + {event.createdAt.toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} +

+
+
+ ))} +
+
+
+ ) +} + diff --git a/src/app/admin/product-syncs/page.tsx b/src/app/admin/product-syncs/page.tsx new file mode 100644 index 0000000..600b203 --- /dev/null +++ b/src/app/admin/product-syncs/page.tsx @@ -0,0 +1,154 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { Shuffle } from "lucide-react" + +interface AdminProductSync { + id: string + status: string + createdAt: string + acceptedAt: string | null + rejectedAt: string | null + shopifyProductId: string + shopifyProductTitle: string + store: { + businessName: string + shopifyStoreName: string + } + supplier: { + companyName: string + } +} + +async function getProductSyncs(): Promise { + const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/product-syncs`, { + cache: "no-store", + }) + if (!res.ok) return [] + const data = (await res.json()) as { syncs: AdminProductSync[] } + return data.syncs +} + +export default async function AdminProductSyncsPage() { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const syncs = await getProductSyncs() + + return ( +
+
+
+

+ Product Syncs +

+

+ All vendor → supplier product sync requests, fully read-only. +

+
+
+ +
+ + + + + + + + + + + + + {syncs.length === 0 && ( + + + + )} + {syncs.map((sync) => ( + + + + + + + + + ))} + +
ProductVendor StoreSupplierStatusRequestedResolved At
+ No product syncs found. +
+
+
+ +
+
+
+ {sync.shopifyProductTitle} +
+
+ #{sync.shopifyProductId} +
+
+
+
+
+ {sync.store.businessName} +
+
+ {sync.store.shopifyStoreName} +
+
+
+ {sync.supplier.companyName} +
+
+ + + {new Date(sync.createdAt).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} + + {sync.acceptedAt || sync.rejectedAt + ? new Date( + (sync.acceptedAt || sync.rejectedAt) as string, + ).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + }) + : "—"} +
+
+
+ ) +} + +function StatusPill({ status }: { status: string }) { + const normalized = status.toUpperCase() + let color = + "bg-slate-800/60 text-slate-200 border-slate-500/40" + + if (normalized === "ACCEPTED") { + color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40" + } else if (normalized === "PENDING") { + color = "bg-amber-500/15 text-amber-200 border-amber-500/40" + } else if (normalized === "REJECTED") { + color = "bg-rose-500/15 text-rose-200 border-rose-500/40" + } + + return ( + + {normalized} + + ) +} + diff --git a/src/app/admin/sync-health/page.tsx b/src/app/admin/sync-health/page.tsx new file mode 100644 index 0000000..cac47af --- /dev/null +++ b/src/app/admin/sync-health/page.tsx @@ -0,0 +1,145 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" +import { Activity, Radar } from "lucide-react" + +interface AdminSyncMetadata { + id: string + resourceType: string + lastSyncAt: string + syncStatus: string + recordCount: number + errorMessage: string | null + store: { + businessName: string + shopifyStoreName: string + } +} + +async function getSyncHealth(): Promise { + const res = await fetch(`${process.env.NEXTAUTH_URL}/api/admin/sync-health`, { + cache: "no-store", + }) + if (!res.ok) return [] + const data = (await res.json()) as { syncs: AdminSyncMetadata[] } + return data.syncs +} + +export default async function AdminSyncHealthPage() { + const session = await auth() + if (!session || session.user.role !== "ADMIN") { + redirect("/login") + } + + const syncs = await getSyncHealth() + + return ( +
+
+
+

+ Sync Health +

+

+ Status of Shopify syncs (products, orders, customers) across all stores. +

+
+
+ +
+ + + + + + + + + + + + + {syncs.length === 0 && ( + + + + )} + {syncs.map((sync) => ( + + + + + + + + + ))} + +
StoreResourceStatusRecordsLast SyncError
+ No sync metadata found. +
+
+
+ +
+
+
+ {sync.store.businessName} +
+
+ {sync.store.shopifyStoreName} +
+
+
+
+ {sync.resourceType.toUpperCase()} + + + + {sync.recordCount} + + {new Date(sync.lastSyncAt).toLocaleString("en-US", { + dateStyle: "short", + timeStyle: "short", + })} + + {sync.errorMessage ? ( +
+ + + {sync.errorMessage} + +
+ ) : ( + — + )} +
+
+
+ ) +} + +function StatusPill({ status }: { status: string }) { + const normalized = status.toUpperCase() + let color = + "bg-slate-800/60 text-slate-200 border-slate-500/40" + + if (normalized === "SUCCESS") { + color = "bg-emerald-500/15 text-emerald-200 border-emerald-500/40" + } else if (normalized === "IN_PROGRESS") { + color = "bg-amber-500/15 text-amber-200 border-amber-500/40" + } else if (normalized === "FAILED") { + color = "bg-rose-500/15 text-rose-200 border-rose-500/40" + } + + return ( + + {normalized} + + ) +} + diff --git a/src/app/api/admin/connections/route.ts b/src/app/api/admin/connections/route.ts new file mode 100644 index 0000000..3dec598 --- /dev/null +++ b/src/app/api/admin/connections/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function GET(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const status = searchParams.get("status") + + const connections = await prisma.connection.findMany({ + where: { + ...(status && { status: status as any }), + }, + include: { + store: true, + supplier: true, + }, + orderBy: { + createdAt: "desc", + }, + }) + + return NextResponse.json({ connections }) + } catch (error) { + console.error("Error fetching admin connections:", error) + return NextResponse.json( + { message: "Failed to fetch connections" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/admin/emails/route.ts b/src/app/api/admin/emails/route.ts new file mode 100644 index 0000000..4a64f42 --- /dev/null +++ b/src/app/api/admin/emails/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function GET(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const limit = parseInt(searchParams.get("limit") || "50", 10) + const storeId = searchParams.get("storeId") + const status = searchParams.get("status") + const trigger = searchParams.get("trigger") + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const logs = await (prisma as any).emailLog.findMany({ + where: { + ...(storeId && { storeId }), + ...(status && { status }), + ...(trigger && { trigger }), + }, + include: { + store: true, + }, + orderBy: { + sentAt: "desc", + }, + take: limit, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const total = await (prisma as any).emailLog.count() + + return NextResponse.json({ emails: logs, total }) + } catch (error) { + console.error("Error fetching admin email logs:", error) + return NextResponse.json( + { message: "Failed to fetch email logs" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts new file mode 100644 index 0000000..54a22b2 --- /dev/null +++ b/src/app/api/admin/events/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +type AdminEventType = "CONNECTION" | "PRODUCT_SYNC" | "SYNC_HEALTH" + +export async function GET(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const limit = parseInt(searchParams.get("limit") || "50", 10) + const typeFilter = searchParams.get("type") as AdminEventType | null + + const [connectionEvents, productSyncEvents, syncHealthEvents] = await Promise.all([ + prisma.connection.findMany({ + orderBy: { createdAt: "desc" }, + take: limit, + include: { + store: true, + supplier: true, + }, + }), + (prisma as any).productSync.findMany({ + orderBy: { createdAt: "desc" }, + take: limit, + include: { + store: true, + supplier: true, + }, + }), + prisma.syncMetadata.findMany({ + orderBy: { lastSyncAt: "desc" }, + take: limit, + include: { + store: true, + }, + }), + ]) + + const events = [ + ...connectionEvents.map((c) => ({ + id: c.id, + type: "CONNECTION" as AdminEventType, + status: c.status, + timestamp: c.createdAt, + storeName: c.store.shopifyStoreName, + supplierName: c.supplier.companyName, + metadata: {}, + })), + ...productSyncEvents.map((p: any) => ({ + id: p.id, + type: "PRODUCT_SYNC" as AdminEventType, + status: p.status, + timestamp: p.createdAt, + storeName: p.store.shopifyStoreName, + supplierName: p.supplier.companyName, + metadata: { + productTitle: p.shopifyProductTitle, + shopifyProductId: p.shopifyProductId, + }, + })), + ...syncHealthEvents.map((s) => ({ + id: s.id, + type: "SYNC_HEALTH" as AdminEventType, + status: s.syncStatus, + timestamp: s.lastSyncAt, + storeName: s.store.shopifyStoreName, + supplierName: null, + metadata: { + resourceType: s.resourceType, + recordCount: s.recordCount, + }, + })), + ] + .filter((event) => !typeFilter || event.type === typeFilter) + .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) + .slice(0, limit) + + return NextResponse.json({ events }) + } catch (error) { + console.error("Error fetching admin events:", error) + return NextResponse.json( + { message: "Failed to fetch events" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/admin/orders/route.ts b/src/app/api/admin/orders/route.ts new file mode 100644 index 0000000..a1cde51 --- /dev/null +++ b/src/app/api/admin/orders/route.ts @@ -0,0 +1,126 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get all accepted product syncs + const acceptedSyncs = await (prisma as any).productSync.findMany({ + where: { + status: "ACCEPTED", + }, + select: { + shopifyProductId: true, + storeId: true, + shopifyProductTitle: true, + }, + }) + + if (acceptedSyncs.length === 0) { + return NextResponse.json({ + orders: [], + syncedProducts: 0, + totalOrders: 0, + }) + } + + const syncsByStore = new Map>() + acceptedSyncs.forEach((sync: any) => { + if (!syncsByStore.has(sync.storeId)) { + syncsByStore.set(sync.storeId, new Set()) + } + syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId) + }) + + const storeIds = Array.from(syncsByStore.keys()) + const cachedOrders = await prisma.cachedOrder.findMany({ + where: { + storeId: { in: storeIds }, + }, + include: { + store: { + select: { + id: true, + businessName: true, + shopifyStoreName: true, + }, + }, + }, + orderBy: { + shopifyCreatedAt: "desc", + }, + }) + + const relevantOrders = cachedOrders.filter((order) => { + const syncedProductIds = syncsByStore.get(order.storeId) + if (!syncedProductIds || syncedProductIds.size === 0) return false + + try { + const lineItems = + typeof order.lineItems === "string" + ? JSON.parse(order.lineItems) + : order.lineItems + + if (!Array.isArray(lineItems)) return false + + return lineItems.some((item: any) => { + const productId = item.product_id?.toString() + return productId && syncedProductIds.has(productId) + }) + } catch { + return false + } + }) + + const transformedOrders = relevantOrders.map((order) => { + const syncedProductIds = syncsByStore.get(order.storeId) || new Set() + let lineItems: any[] = [] + + try { + lineItems = + typeof order.lineItems === "string" + ? JSON.parse(order.lineItems) + : order.lineItems + } catch { + lineItems = [] + } + + const syncedLineItems = lineItems.filter((item: any) => { + const productId = item.product_id?.toString() + return productId && syncedProductIds.has(productId) + }) + + return { + id: order.shopifyOrderId, + orderNumber: order.orderNumber, + email: order.email, + totalPrice: order.totalPrice, + currency: order.currency, + financialStatus: order.financialStatus, + fulfillmentStatus: order.fulfillmentStatus, + createdAt: order.shopifyCreatedAt || order.createdAt, + store: order.store, + lineItems: syncedLineItems, + } + }) + + return NextResponse.json({ + orders: transformedOrders, + syncedProducts: acceptedSyncs.length, + totalOrders: transformedOrders.length, + }) + } catch (error) { + console.error("Error fetching admin orders:", error) + return NextResponse.json( + { message: "Failed to fetch orders" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/admin/product-syncs/route.ts b/src/app/api/admin/product-syncs/route.ts new file mode 100644 index 0000000..2dd971d --- /dev/null +++ b/src/app/api/admin/product-syncs/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function GET(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const status = searchParams.get("status") + + const syncs = await (prisma as any).productSync.findMany({ + where: { + ...(status && { status }), + }, + include: { + store: true, + supplier: true, + }, + orderBy: { + createdAt: "desc", + }, + }) + + return NextResponse.json({ syncs }) + } catch (error) { + console.error("Error fetching admin product syncs:", error) + return NextResponse.json( + { message: "Failed to fetch product syncs" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/admin/sync-health/route.ts b/src/app/api/admin/sync-health/route.ts new file mode 100644 index 0000000..6cdcfc2 --- /dev/null +++ b/src/app/api/admin/sync-health/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "ADMIN") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const syncs = await prisma.syncMetadata.findMany({ + include: { + store: true, + }, + orderBy: { + lastSyncAt: "desc", + }, + }) + + return NextResponse.json({ syncs }) + } catch (error) { + console.error("Error fetching admin sync health:", error) + return NextResponse.json( + { message: "Failed to fetch sync health" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..42e2953 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from "@/auth" +export const { GET, POST } = handlers diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..bd01df6 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,162 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import bcrypt from "bcryptjs" +import { decryptString } from "@/lib/encryption" +import { applyRateLimit } from "@/lib/rate-limit" +import { authLoginSchema } from "@/lib/validation" +import { logger } from "@/lib/logger" + +const ADMIN_EMAIL = process.env.ADMIN_EMAIL +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD + +export async function POST(req: Request) { + try { + // Basic rate limiting per IP for login attempts + const rate = await applyRateLimit(req, "auth:login") + if (!rate.success) { + return NextResponse.json( + { message: "Too many login attempts. Please try again later." }, + { status: 429 } + ) + } + + const body = await req.json() + const parseResult = authLoginSchema.safeParse(body) + + if (!parseResult.success) { + const message = parseResult.error.issues[0]?.message || "Invalid login data." + return NextResponse.json({ message }, { status: 400 }) + } + + const { email, password, mfaCode } = parseResult.data + + if (!email || !password || email.trim() === "" || password.trim() === "") { + return NextResponse.json( + { message: "Email and password are required" }, + { status: 400 }, + ) + } + + // Special-case env-configured admin user so it can log in even if not pre-seeded + if (ADMIN_EMAIL && ADMIN_PASSWORD && email === ADMIN_EMAIL) { + const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 10) + + const adminUser = await prisma.user.upsert({ + where: { email: ADMIN_EMAIL }, + update: { + password: hashedPassword, + role: "ADMIN", + }, + create: { + email: ADMIN_EMAIL, + password: hashedPassword, + role: "ADMIN", + name: "Admin", + }, + }) + + const isValidAdminPassword = await bcrypt.compare(password, adminUser.password!) + if (!isValidAdminPassword) { + return NextResponse.json( + { message: "Invalid email or password" }, + { status: 401 }, + ) + } + + // Admin currently does not go through MFA in this flow + return NextResponse.json({ + message: "Authentication successful", + requiresMFA: false, + verified: true, + }) + } + + // Regular user login flow + const userResult = await prisma.$queryRawUnsafe< + Array<{ + id: string + password: string | null + mfaEnabled: boolean | null + mfaSecret: string | null + }> + >( + `SELECT id, password, "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`, + email, + ) + + if (!userResult || userResult.length === 0) { + return NextResponse.json( + { message: "Invalid email or password" }, + { status: 401 }, + ) + } + + const user = userResult[0] + + if (!user.password) { + return NextResponse.json( + { message: "Invalid email or password" }, + { status: 401 }, + ) + } + + // Verify password + const isValidPassword = await bcrypt.compare(password, user.password) + if (!isValidPassword) { + return NextResponse.json( + { message: "Invalid email or password" }, + { status: 401 }, + ) + } + + // Check if MFA is enabled + const mfaEnabled = user.mfaEnabled ?? false + + if (mfaEnabled) { + // If MFA code is not provided, return that MFA is required + if (!mfaCode) { + return NextResponse.json( + { + message: "MFA verification required", + requiresMFA: true + }, + { status: 200 } + ) + } + + // Verify MFA code + const mfaSecret = decryptString(user.mfaSecret) + if (!mfaSecret) { + return NextResponse.json( + { message: "MFA is enabled but no secret found. Please contact support." }, + { status: 500 } + ) + } + + const { verify } = await import("otplib") + const verifyResult = await verify({ token: mfaCode, secret: mfaSecret }) + const isValidMFA = verifyResult.valid ?? false + + if (!isValidMFA) { + return NextResponse.json( + { message: "Invalid verification code" }, + { status: 401 } + ) + } + } + + // If we get here, credentials and MFA (if enabled) are valid + // Return success - the client will call NextAuth signIn + return NextResponse.json({ + message: "Authentication successful", + requiresMFA: false, + verified: true + }) + } catch (error) { + logger.error("Login error", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts new file mode 100644 index 0000000..17ad14f --- /dev/null +++ b/src/app/api/auth/password-reset/request/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { sendEmail } from "@/lib/email" +import { emailSchema } from "@/lib/validation" +import { applyRateLimit } from "@/lib/rate-limit" +import { handleApiError } from "@/lib/errors" +import crypto from "crypto" + +export async function POST(req: Request) { + try { + const rate = await applyRateLimit(req, "auth:password-reset-request") + if (!rate.success) { + return NextResponse.json( + { message: "Too many requests. Please try again later." }, + { status: 429 } + ) + } + + const body = await req.json() + const emailResult = emailSchema.safeParse(body.email) + + if (!emailResult.success) { + return NextResponse.json( + { message: emailResult.error.errors[0].message }, + { status: 400 } + ) + } + + const email = emailResult.data + + const user = await prisma.user.findUnique({ + where: { email } + }) + + // Don't reveal if user exists or not (security best practice) + if (!user) { + return NextResponse.json({ + message: "If an account with that email exists, a password reset link has been sent." + }) + } + + // Generate reset token + const resetToken = crypto.randomBytes(32).toString("hex") + const expiresAt = new Date() + expiresAt.setHours(expiresAt.getHours() + 1) // Token expires in 1 hour + + // Store token in VerificationToken table + await prisma.verificationToken.upsert({ + where: { + identifier_token: { + identifier: email, + token: resetToken + } + }, + update: { + token: resetToken, + expires: expiresAt + }, + create: { + identifier: email, + token: resetToken, + expires: expiresAt + } + }) + + // Send reset email + const resetUrl = `${process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/reset-password?token=${resetToken}&email=${encodeURIComponent(email)}` + + const emailHtml = ` + + + + + + Password Reset - JUNOHUB + + + + + + +
+ + + + +
+

Reset Your Password

+

+ Click the button below to reset your password. This link will expire in 1 hour. +

+ + Reset Password + +

+ If you didn't request this, you can safely ignore this email. +

+
+
+ + + ` + + await sendEmail({ + to: email, + subject: "Reset Your JUNOHUB Password", + html: emailHtml + }) + + return NextResponse.json({ + message: "If an account with that email exists, a password reset link has been sent." + }) + } catch (error) { + return handleApiError(error, "password-reset-request") + } +} diff --git a/src/app/api/auth/password-reset/reset/route.ts b/src/app/api/auth/password-reset/reset/route.ts new file mode 100644 index 0000000..00b90f5 --- /dev/null +++ b/src/app/api/auth/password-reset/reset/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { passwordSchema, emailSchema } from "@/lib/validation" +import { applyRateLimit } from "@/lib/rate-limit" +import { handleApiError } from "@/lib/errors" +import bcrypt from "bcryptjs" + +export async function POST(req: Request) { + try { + const rate = await applyRateLimit(req, "auth:password-reset") + if (!rate.success) { + return NextResponse.json( + { message: "Too many requests. Please try again later." }, + { status: 429 } + ) + } + + const body = await req.json() + const { token, email, password } = body + + if (!token || !email || !password) { + return NextResponse.json( + { message: "Token, email, and password are required" }, + { status: 400 } + ) + } + + // Validate email and password + const emailResult = emailSchema.safeParse(email) + const passwordResult = passwordSchema.safeParse(password) + + if (!emailResult.success) { + return NextResponse.json( + { message: emailResult.error.errors[0].message }, + { status: 400 } + ) + } + + if (!passwordResult.success) { + return NextResponse.json( + { message: passwordResult.error.errors[0].message }, + { status: 400 } + ) + } + + // Verify token + const verificationToken = await prisma.verificationToken.findUnique({ + where: { + identifier_token: { + identifier: email, + token: token + } + } + }) + + if (!verificationToken || verificationToken.expires < new Date()) { + return NextResponse.json( + { message: "Invalid or expired reset token" }, + { status: 400 } + ) + } + + // Update password + const hashedPassword = await bcrypt.hash(password, 10) + await prisma.user.update({ + where: { email }, + data: { password: hashedPassword } + }) + + // Delete used token + await prisma.verificationToken.delete({ + where: { + identifier_token: { + identifier: email, + token: token + } + } + }) + + return NextResponse.json({ + message: "Password has been reset successfully" + }) + } catch (error) { + return handleApiError(error, "password-reset") + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..cdf4ba4 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import bcrypt from "bcryptjs" +import { applyRateLimit } from "@/lib/rate-limit" +import { authRegisterSchema } from "@/lib/validation" + +export async function POST(req: Request) { + try { + const body = await req.json() + + // Basic rate limiting per IP for registrations + const rate = await applyRateLimit(req, "auth:register") + if (!rate.success) { + return NextResponse.json( + { message: "Too many signup attempts. Please try again later." }, + { status: 429 } + ) + } + + const parseResult = authRegisterSchema.safeParse(body) + + if (!parseResult.success) { + const message = parseResult.error.issues[0]?.message || "Invalid registration data." + return NextResponse.json({ message }, { status: 400 }) + } + + const { name, email, password, role } = parseResult.data + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email } + }) + + if (existingUser) { + return NextResponse.json({ message: "User already exists with this email." }, { status: 400 }) + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10) + + // Create user + const user = await prisma.user.create({ + data: { + name, + email, + password: hashedPassword, + role: role || "VENDOR" + } + }) + + return NextResponse.json({ + message: "User registered successfully!", + user: { id: user.id, name: user.name, email: user.email } + }) + + } catch (error) { + console.error("Registration error:", error) + return NextResponse.json({ message: "Internal server error during registration." }, { status: 500 }) + } +} diff --git a/src/app/api/auth/verify-email/send/route.ts b/src/app/api/auth/verify-email/send/route.ts new file mode 100644 index 0000000..f8af86c --- /dev/null +++ b/src/app/api/auth/verify-email/send/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { sendEmail } from "@/lib/email" +import { applyRateLimit } from "@/lib/rate-limit" +import { handleApiError } from "@/lib/errors" +import crypto from "crypto" + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const rate = await applyRateLimit(req, "auth:verify-email-send") + if (!rate.success) { + return NextResponse.json( + { message: "Too many requests. Please try again later." }, + { status: 429 } + ) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id } + }) + + if (!user || !user.email) { + return NextResponse.json( + { message: "User not found or email not set" }, + { status: 404 } + ) + } + + if (user.emailVerified) { + return NextResponse.json({ + message: "Email is already verified" + }) + } + + // Generate verification token + const verificationToken = crypto.randomBytes(32).toString("hex") + const expiresAt = new Date() + expiresAt.setHours(expiresAt.getHours() + 24) // Token expires in 24 hours + + // Store token + await prisma.verificationToken.upsert({ + where: { + identifier_token: { + identifier: user.email, + token: verificationToken + } + }, + update: { + token: verificationToken, + expires: expiresAt + }, + create: { + identifier: user.email, + token: verificationToken, + expires: expiresAt + } + }) + + // Send verification email + const verifyUrl = `${process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/verify-email?token=${verificationToken}&email=${encodeURIComponent(user.email)}` + + const emailHtml = ` + + + + + + Verify Your Email - JUNOHUB + + + + + + +
+ + + + +
+

Verify Your Email

+

+ Please verify your email address to complete your account setup. +

+ + Verify Email + +

+ This link will expire in 24 hours. +

+
+
+ + + ` + + await sendEmail({ + to: user.email, + subject: "Verify Your JUNOHUB Email", + html: emailHtml + }) + + return NextResponse.json({ + message: "Verification email sent successfully" + }) + } catch (error) { + return handleApiError(error, "verify-email-send") + } +} diff --git a/src/app/api/auth/verify-email/verify/route.ts b/src/app/api/auth/verify-email/verify/route.ts new file mode 100644 index 0000000..2ee7e82 --- /dev/null +++ b/src/app/api/auth/verify-email/verify/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { applyRateLimit } from "@/lib/rate-limit" +import { handleApiError } from "@/lib/errors" + +export async function POST(req: Request) { + try { + const rate = await applyRateLimit(req, "auth:verify-email") + if (!rate.success) { + return NextResponse.json( + { message: "Too many requests. Please try again later." }, + { status: 429 } + ) + } + + const body = await req.json() + const { token, email } = body + + if (!token || !email) { + return NextResponse.json( + { message: "Token and email are required" }, + { status: 400 } + ) + } + + // Verify token + const verificationToken = await prisma.verificationToken.findUnique({ + where: { + identifier_token: { + identifier: email, + token: token + } + } + }) + + if (!verificationToken || verificationToken.expires < new Date()) { + return NextResponse.json( + { message: "Invalid or expired verification token" }, + { status: 400 } + ) + } + + // Update user emailVerified + await prisma.user.update({ + where: { email }, + data: { emailVerified: new Date() } + }) + + // Delete used token + await prisma.verificationToken.delete({ + where: { + identifier_token: { + identifier: email, + token: token + } + } + }) + + return NextResponse.json({ + message: "Email verified successfully" + }) + } catch (error) { + return handleApiError(error, "verify-email") + } +} diff --git a/src/app/api/auth/verify-mfa/route.ts b/src/app/api/auth/verify-mfa/route.ts new file mode 100644 index 0000000..1648b60 --- /dev/null +++ b/src/app/api/auth/verify-mfa/route.ts @@ -0,0 +1,73 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { verify } from "otplib" +import { decryptString } from "@/lib/encryption" +import { applyRateLimit } from "@/lib/rate-limit" + +export async function POST(req: Request) { + try { + const { email, code } = await req.json() + + // Rate limit MFA verification attempts per IP + const rate = await applyRateLimit(req, "auth:mfa-verify") + if (!rate.success) { + return NextResponse.json( + { message: "Too many verification attempts. Please try again later." }, + { status: 429 } + ) + } + + if (!email || !code) { + return NextResponse.json( + { message: "Email and verification code are required" }, + { status: 400 } + ) + } + + // Use raw query to access MFA fields until Prisma client is fully regenerated + const userResult = await prisma.$queryRawUnsafe>( + `SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE email = $1`, + email + ) + + if (!userResult || userResult.length === 0) { + return NextResponse.json( + { message: "User not found" }, + { status: 404 } + ) + } + + const user = userResult[0] + const mfaEnabled = user.mfaEnabled ?? false + const mfaSecret = decryptString(user.mfaSecret) + + if (!mfaEnabled || !mfaSecret) { + return NextResponse.json( + { message: "MFA is not enabled for this account" }, + { status: 400 } + ) + } + + // Verify the TOTP code + const verifyResult = await verify({ token: code, secret: mfaSecret }) + const isValid = verifyResult.valid ?? false + + if (!isValid) { + return NextResponse.json( + { message: "Invalid verification code" }, + { status: 400 } + ) + } + + return NextResponse.json({ + message: "MFA verification successful", + verified: true + }) + } catch (error) { + console.error("MFA verification error:", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/integrations/gmail/callback/route.ts b/src/app/api/integrations/gmail/callback/route.ts new file mode 100644 index 0000000..7755311 --- /dev/null +++ b/src/app/api/integrations/gmail/callback/route.ts @@ -0,0 +1,163 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/integrations/gmail/callback + * Handle Google OAuth callback and store tokens + */ +export async function GET(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.redirect( + new URL("/login?error=unauthorized", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } + + const { searchParams } = new URL(req.url) + const code = searchParams.get("code") + const error = searchParams.get("error") + const state = searchParams.get("state") + + // Handle OAuth errors + if (error) { + console.error("Gmail OAuth error:", error) + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?error=oauth_failed", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } + + if (!code) { + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?error=no_code", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } + + // Exchange authorization code for tokens + // IMPORTANT: This redirect URI must exactly match what was sent in the authorization request + const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" + const redirectUri = `${baseUrl}/api/integrations/gmail/callback` + + console.log("Gmail OAuth Callback - Redirect URI:", redirectUri) // Log for debugging + + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID!, + client_secret: process.env.GOOGLE_CLIENT_SECRET!, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }), + }) + + if (!tokenResponse.ok) { + const errorData = await tokenResponse.json().catch(() => ({})) + console.error("Token exchange error:", errorData) + console.error("Redirect URI used:", redirectUri) + + // Provide more specific error message if it's a redirect_uri_mismatch + if (errorData.error === "redirect_uri_mismatch") { + return NextResponse.redirect( + new URL( + `/dashboard/juno-engine/gmail-connect?error=redirect_uri_mismatch&redirect_uri=${encodeURIComponent(redirectUri)}`, + process.env.NEXTAUTH_URL || "http://localhost:3000" + ) + ) + } + + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?error=token_exchange_failed", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } + + const tokens = await tokenResponse.json() + + // Get user's Gmail email address + // Try userinfo endpoint first (requires email/profile scopes) + let userInfo: { email: string } | null = null + + try { + const userInfoResponse = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }) + + if (userInfoResponse.ok) { + userInfo = await userInfoResponse.json() + } else { + // Fallback: Try Gmail API profile endpoint + const gmailProfileResponse = await fetch("https://gmail.googleapis.com/gmail/v1/users/me/profile", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + }) + + if (gmailProfileResponse.ok) { + const profile = await gmailProfileResponse.json() + userInfo = { email: profile.emailAddress } + } else { + const errorData = await gmailProfileResponse.json().catch(() => ({})) + console.error("Failed to fetch user info from both endpoints:", { + userinfo: userInfoResponse.status, + gmail: gmailProfileResponse.status, + error: errorData + }) + throw new Error("Failed to fetch user email") + } + } + } catch (error) { + console.error("Error fetching user info:", error) + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } + + if (!userInfo?.email) { + console.error("No email found in user info") + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?error=user_info_failed", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } + + // Calculate token expiration + const expiresAt = new Date(Date.now() + (tokens.expires_in * 1000)) + + // Store or update Gmail integration + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (prisma as any).gmailIntegration.upsert({ + where: { + userId: session.user.id + }, + create: { + userId: session.user.id, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresAt, + email: userInfo.email, + scope: tokens.scope || "" + }, + update: { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresAt, + email: userInfo.email, + scope: tokens.scope || "" + } + }) + + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?connected=true", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } catch (error) { + console.error("Gmail callback error:", error) + return NextResponse.redirect( + new URL("/dashboard/juno-engine/gmail-connect?error=callback_failed", process.env.NEXTAUTH_URL || "http://localhost:3000") + ) + } +} diff --git a/src/app/api/integrations/gmail/connect/route.ts b/src/app/api/integrations/gmail/connect/route.ts new file mode 100644 index 0000000..2bb140e --- /dev/null +++ b/src/app/api/integrations/gmail/connect/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import crypto from "crypto" + +export async function POST() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Check if Google OAuth credentials are configured + if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { + return NextResponse.json( + { message: "Google OAuth credentials not configured. Please set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables." }, + { status: 500 } + ) + } + + // Generate state token for CSRF protection + const state = crypto.randomBytes(32).toString("hex") + + // Store state in a cookie or session (for simplicity, we'll include it in the callback URL) + // In production, use a proper session store or encrypted cookie + + // Gmail API scopes + // Adding 'email' and 'profile' scopes to get user email address + const scopes = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" + ].join(" ") + + // Build Google OAuth 2.0 authorization URL + // IMPORTANT: The redirect URI must exactly match what's configured in Google Cloud Console + // For production: https://yourdomain.com/api/integrations/gmail/callback + // For development: http://localhost:3000/api/integrations/gmail/callback + // For ngrok: https://your-ngrok-url.ngrok-free.dev/api/integrations/gmail/callback + const baseUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" + const redirectUri = `${baseUrl}/api/integrations/gmail/callback` + + console.log("Gmail OAuth - Redirect URI:", redirectUri) // Log for debugging + + const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth") + authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID) + authUrl.searchParams.set("redirect_uri", redirectUri) + authUrl.searchParams.set("response_type", "code") + authUrl.searchParams.set("scope", scopes) + authUrl.searchParams.set("access_type", "offline") // Required to get refresh token + authUrl.searchParams.set("prompt", "consent") // Force consent to get refresh token + authUrl.searchParams.set("state", state) + + return NextResponse.json({ + authUrl: authUrl.toString(), + state, // Return state so frontend can verify it on callback + redirectUri, // Include redirect URI in response for debugging + message: "Redirecting to Google OAuth" + }) + } catch (error) { + console.error("Error initiating Gmail OAuth:", error) + return NextResponse.json( + { message: "Failed to initiate Gmail connection" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/integrations/gmail/disconnect/route.ts b/src/app/api/integrations/gmail/disconnect/route.ts new file mode 100644 index 0000000..cd8f4b2 --- /dev/null +++ b/src/app/api/integrations/gmail/disconnect/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { handleApiError } from "@/lib/errors" + +export async function POST() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Delete Gmail integration + await prisma.gmailIntegration.deleteMany({ + where: { + userId: session.user.id + } + }) + + return NextResponse.json({ + message: "Gmail disconnected successfully" + }) + } catch (error) { + return handleApiError(error, "gmail-disconnect") + } +} diff --git a/src/app/api/integrations/gmail/poll/route.ts b/src/app/api/integrations/gmail/poll/route.ts new file mode 100644 index 0000000..ce1a0f6 --- /dev/null +++ b/src/app/api/integrations/gmail/poll/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { getUnreadEmails, sendGmailReply, markEmailAsRead } from "@/lib/gmail/emailService" + +/** + * POST /api/integrations/gmail/poll + * Manually trigger email polling and automated replies + * This can be called periodically via cron job or manually + */ +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get user's active store + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + stores: { + where: { isActive: true }, + take: 1 + } + } + }) + + const storeId = user?.stores[0]?.id + + // Process incoming emails + const unreadEmails = await getUnreadEmails(session.user.id, 10) + const processed: string[] = [] + const failed: string[] = [] + + for (const email of unreadEmails) { + try { + // Extract email domain from sender + const emailMatch = email.from.match(/<(.+)>/) + const senderEmail = emailMatch ? emailMatch[1] : email.from.split(" ").pop() || email.from + const senderDomain = senderEmail.split("@")[1]?.toLowerCase() + + // Only reply to gmail.com addresses + if (senderDomain !== "gmail.com") { + logger.info("Skipping email - not from gmail.com", { + userId: session.user.id, + from: email.from, + domain: senderDomain + }) + continue + } + + // Send automated reply (dummy response for testing) + const replySubject = email.subject.startsWith("Re:") ? email.subject : `Re: ${email.subject}` + const replyBody = ` +
+

Thank you for your email. This is an automated reply.

+

We have received your message and will get back to you soon.

+
+

Best regards,
JUNOHUB Support Team

+
+ ` + + const result = await sendGmailReply( + session.user.id, + senderEmail, + replySubject, + replyBody, + email.threadId, + { + storeId, + isAutomated: true, + trigger: "GMAIL_AUTO_REPLY", + originalMessageId: email.messageId, + } + ) + + if (result.success) { + // Mark email as read after replying + await markEmailAsRead(session.user.id, email.id) + processed.push(email.id) + logger.info("Automated reply sent", { + userId: session.user.id, + to: senderEmail, + subject: email.subject + }) + } else { + failed.push(email.id) + logger.error("Failed to send automated reply", { + userId: session.user.id, + to: senderEmail, + error: result.error + }) + } + } catch (error) { + failed.push(email.id) + logger.error("Error processing email", { emailId: email.id, error }) + } + } + + return NextResponse.json({ + success: true, + processed: processed.length, + failed: failed.length, + total: unreadEmails.length + }) + } catch (error) { + logger.error("Email polling error", error) + return NextResponse.json( + { message: "Failed to poll emails" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/integrations/gmail/status/route.ts b/src/app/api/integrations/gmail/status/route.ts new file mode 100644 index 0000000..75e7eef --- /dev/null +++ b/src/app/api/integrations/gmail/status/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { handleApiError } from "@/lib/errors" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Check if user has a Gmail integration connected + const gmailIntegration = await prisma.gmailIntegration.findUnique({ + where: { + userId: session.user.id + } + }) + + // Check if token is expired + const isExpired = gmailIntegration && gmailIntegration.expiresAt < new Date() + + return NextResponse.json({ + connected: !!gmailIntegration && !isExpired, + email: gmailIntegration?.email || null, + expiresAt: gmailIntegration?.expiresAt || null + }) + } catch (error) { + return handleApiError(error, "gmail-status") + } +} diff --git a/src/app/api/juno-engine/tickets/route.ts b/src/app/api/juno-engine/tickets/route.ts new file mode 100644 index 0000000..80f0598 --- /dev/null +++ b/src/app/api/juno-engine/tickets/route.ts @@ -0,0 +1,139 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" + +// Normalize subject by stripping common reply prefixes so we can group conversations +function normalizeSubject(subject: string | null): string { + if (!subject) return "No subject" + let s = subject.trim() + // Remove repeated "Re:" / "Fwd:" prefixes + // e.g. "Re: Re: Hello" -> "Hello" + // Case-insensitive, allow localized variants in future by trimming until no prefix matches + // eslint-disable-next-line no-constant-condition + while (true) { + const match = s.match(/^(re|fw|fwd):\s*/i) + if (!match) break + s = s.slice(match[0].length).trim() + } + return s || "No subject" +} + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + if (session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Forbidden" }, { status: 403 }) + } + + // Find the vendor's active store to scope tickets + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + include: { + stores: { + where: { isActive: true }, + take: 1, + select: { id: true } + } + } + }) + + const activeStore = user?.stores[0] + if (!activeStore) { + return NextResponse.json({ tickets: [] }) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const logs = await (prisma as any).emailLog.findMany({ + where: { + storeId: activeStore.id, + isAutomated: true, + }, + orderBy: { + sentAt: "desc", + }, + take: 200, + }) + + type TicketMessage = { + id: string + subject: string + bodyPreview: string | null + status: string + trigger: string | null + sentAt: string + } + + type Ticket = { + id: string + subject: string + rootSubject: string + customerEmail: string + status: string + updatedAt: string + messagesCount: number + lastMessageSnippet: string + messages: TicketMessage[] + } + + const ticketsMap = new Map() + + for (const log of logs as Array<{ + id: string + to: string + subject: string | null + bodyPreview: string | null + status: string + trigger: string | null + sentAt: Date + }>) { + const customerEmail = (log.to || "").toLowerCase() + const rootSubject = normalizeSubject(log.subject) + const key = `${customerEmail}::${rootSubject}` + + const baseMessage: TicketMessage = { + id: log.id, + subject: log.subject || rootSubject, + bodyPreview: log.bodyPreview, + status: log.status, + trigger: log.trigger, + sentAt: log.sentAt.toISOString(), + } + + const existing = ticketsMap.get(key) + if (!existing) { + ticketsMap.set(key, { + id: key, + subject: log.subject || rootSubject, + rootSubject, + customerEmail, + status: log.status, + updatedAt: log.sentAt.toISOString(), + messagesCount: 1, + lastMessageSnippet: log.bodyPreview || "", + messages: [baseMessage], + }) + } else { + existing.messages.push(baseMessage) + existing.messagesCount += 1 + // The logs are ordered newest-first, so the first seen message is the latest + // Keep lastMessageSnippet / updatedAt from the first entry + } + } + + const tickets = Array.from(ticketsMap.values()) + + return NextResponse.json({ tickets }) + } catch (error) { + logger.error("Failed to load vendor tickets from email logs", error) + return NextResponse.json( + { message: "Failed to load tickets", tickets: [] }, + { status: 500 } + ) + } +} + diff --git a/src/app/api/notifications/[notificationId]/route.ts b/src/app/api/notifications/[notificationId]/route.ts new file mode 100644 index 0000000..c16060e --- /dev/null +++ b/src/app/api/notifications/[notificationId]/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * PUT /api/notifications/[notificationId] + * Mark a specific notification as read + */ +export async function PUT( + req: Request, + { params }: { params: Promise<{ notificationId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { notificationId } = await params + + // Verify notification belongs to user + const notification = await prisma.notification.findFirst({ + where: { + id: notificationId, + userId: session.user.id + } + }) + + if (!notification) { + return NextResponse.json({ message: "Notification not found" }, { status: 404 }) + } + + // Mark as read + await prisma.notification.update({ + where: { id: notificationId }, + data: { + read: true, + readAt: new Date() + } + }) + + return NextResponse.json({ message: "Notification marked as read" }) + } catch (error) { + console.error("Error updating notification:", error) + return NextResponse.json( + { message: "Failed to update notification" }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/notifications/[notificationId] + * Delete a notification + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ notificationId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { notificationId } = await params + + // Verify notification belongs to user + const notification = await prisma.notification.findFirst({ + where: { + id: notificationId, + userId: session.user.id + } + }) + + if (!notification) { + return NextResponse.json({ message: "Notification not found" }, { status: 404 }) + } + + await prisma.notification.delete({ + where: { id: notificationId } + }) + + return NextResponse.json({ message: "Notification deleted" }) + } catch (error) { + console.error("Error deleting notification:", error) + return NextResponse.json( + { message: "Failed to delete notification" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts new file mode 100644 index 0000000..0c4bbf4 --- /dev/null +++ b/src/app/api/notifications/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/notifications + * Get all notifications for the authenticated user + */ +export async function GET(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const unreadOnly = searchParams.get("unread") === "true" + const limit = parseInt(searchParams.get("limit") || "50") + + const notifications = await prisma.notification.findMany({ + where: { + userId: session.user.id, + ...(unreadOnly && { read: false }) + }, + orderBy: { + createdAt: "desc" + }, + take: limit + }) + + const unreadCount = await prisma.notification.count({ + where: { + userId: session.user.id, + read: false + } + }) + + return NextResponse.json({ + notifications, + unreadCount + }) + } catch (error) { + console.error("Error fetching notifications:", error) + return NextResponse.json( + { message: "Failed to fetch notifications" }, + { status: 500 } + ) + } +} + +/** + * POST /api/notifications + * Mark notifications as read + */ +export async function POST(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const body = await req.json() + const { notificationIds, markAllAsRead } = body + + if (markAllAsRead) { + // Mark all notifications as read for this user + await prisma.notification.updateMany({ + where: { + userId: session.user.id, + read: false + }, + data: { + read: true, + readAt: new Date() + } + }) + + return NextResponse.json({ message: "All notifications marked as read" }) + } + + if (notificationIds && Array.isArray(notificationIds) && notificationIds.length > 0) { + // Mark specific notifications as read + await prisma.notification.updateMany({ + where: { + id: { in: notificationIds }, + userId: session.user.id + }, + data: { + read: true, + readAt: new Date() + } + }) + + return NextResponse.json({ message: "Notifications marked as read" }) + } + + return NextResponse.json( + { message: "Invalid request. Provide notificationIds array or markAllAsRead: true" }, + { status: 400 } + ) + } catch (error) { + console.error("Error updating notifications:", error) + return NextResponse.json( + { message: "Failed to update notifications" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/onboarding/check/route.ts b/src/app/api/onboarding/check/route.ts new file mode 100644 index 0000000..e0c3aed --- /dev/null +++ b/src/app/api/onboarding/check/route.ts @@ -0,0 +1,26 @@ +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { NextResponse } from "next/server" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + if (session.user.role !== "VENDOR") { + return NextResponse.json({ complete: true }) + } + + const store = await prisma.store.findFirst({ + where: { userId: session.user.id }, + }) + + return NextResponse.json({ + complete: !!store?.onboardingComplete + }) + } catch { + return NextResponse.json({ message: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts new file mode 100644 index 0000000..fb1d0d2 --- /dev/null +++ b/src/app/api/onboarding/complete/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" + +export async function POST(req: NextRequest) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const body = await req.json() + const { + businessName, + businessEmail, + country, + timezone, + storeName, + domain, + apiKey, + apiSecret, + accessToken + } = body + + // Validate required fields + if (!businessName || !domain || !accessToken) { + return NextResponse.json({ message: "Missing required onboarding data." }, { status: 400 }) + } + + // Clean domain: remove protocol, trailing slashes, and trim whitespace + const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + // Multi-store: find store by user + domain, then update or create + let store = await prisma.store.findFirst({ + where: { userId: session.user.id, shopifyDomain: cleanDomain }, + }) + + if (store) { + store = await prisma.store.update({ + where: { id: store.id }, + data: { + businessName, + email: businessEmail ?? store.email, + country: country ?? store.country, + timezone: timezone ?? store.timezone, + shopifyStoreName: storeName ?? store.shopifyStoreName, + shopifyDomain: cleanDomain, + shopifyApiKey: apiKey ?? store.shopifyApiKey, + shopifyApiSecret: apiSecret ?? store.shopifyApiSecret, + shopifyAccessToken: accessToken, + onboardingComplete: true, + }, + }) + } else { + const existingCount = await prisma.store.count({ where: { userId: session.user.id } }) + store = await prisma.store.create({ + data: { + userId: session.user.id, + businessName, + email: businessEmail || session.user.email || "", + country: country || "US", + timezone: timezone || "UTC", + shopifyStoreName: storeName || "", + shopifyDomain: cleanDomain, + shopifyApiKey: apiKey || "", + shopifyApiSecret: apiSecret || "", + shopifyAccessToken: accessToken, + onboardingComplete: true, + isActive: existingCount === 0, + }, + }) + if (existingCount === 0) { + await prisma.user.update({ + where: { id: session.user.id }, + data: { activeStoreId: store.id }, + }) + } + } + + // Register Webhooks with Shopify + const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app" + const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"] + + for (const topic of webhookTopics) { + try { + await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, { + method: "POST", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + webhook: { + topic, + address: `${appUrl}/api/webhooks/shopify`, + format: "json" + } + }) + }) + logger.info(`Registered Shopify webhook: ${topic}`) + } catch (err) { + logger.error(`Failed to register webhook ${topic}`, err) + } + } + + return NextResponse.json({ + message: "Onboarding completed and automation registered!", + store + }) + + } catch (error) { + logger.error("Onboarding completion error:", error) + return NextResponse.json({ message: "Internal server error while saving data." }, { status: 500 }) + } +} diff --git a/src/app/api/onboarding/shopify/route.ts b/src/app/api/onboarding/shopify/route.ts new file mode 100644 index 0000000..5142fe4 --- /dev/null +++ b/src/app/api/onboarding/shopify/route.ts @@ -0,0 +1,132 @@ +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { NextResponse } from "next/server" +import { logger } from "@/lib/logger" + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { + businessName, + storeName, + domain, + apiKey, + apiSecret, + accessToken + } = await req.json() + + // Basic validation + if (!businessName || !storeName || !domain || !apiKey || !apiSecret || !accessToken) { + return NextResponse.json({ message: "Missing required fields" }, { status: 400 }) + } + + // Multi-store: create a new store; set as active if first store + const existingCount = await prisma.store.count({ where: { userId: session.user.id } }) + const isFirstStore = existingCount === 0 + + // Clean domain: remove protocol, trailing slashes, and trim whitespace + const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + const store = await prisma.store.create({ + data: { + userId: session.user.id, + businessName, + email: session.user.email || "", + timezone: "UTC", + country: "US", + shopifyStoreName: storeName, + shopifyDomain: cleanDomain, + shopifyApiKey: apiKey, + shopifyApiSecret: apiSecret, + shopifyAccessToken: accessToken, + onboardingComplete: true, + isActive: isFirstStore, + }, + }) + + if (isFirstStore) { + await prisma.user.update({ + where: { id: session.user.id }, + data: { activeStoreId: store.id }, + }) + } + + // Register webhooks for the new store + const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app" + const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"] + const webhookAddress = `${appUrl}/api/webhooks/shopify` + + // First, check existing webhooks to avoid duplicates + let existingWebhooks: Array<{ topic: string; id: number; address: string }> = [] + try { + const listResponse = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, { + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json" + } + }) + if (listResponse.ok) { + const webhooksData = await listResponse.json() + existingWebhooks = (webhooksData.webhooks || []).map((wh: { topic: string; id: number; address: string }) => ({ + topic: wh.topic, + id: wh.id, + address: wh.address + })) + } + } catch (err) { + logger.warn(`Could not fetch existing webhooks for store ${store.id}`, err) + } + + for (const topic of webhookTopics) { + try { + // Check if webhook already exists with same address + const existingWebhook = existingWebhooks.find( + (wh) => wh.topic === topic && wh.address === webhookAddress + ) + + if (existingWebhook) { + logger.info(`Webhook ${topic} already exists for address ${webhookAddress}, skipping registration`) + continue + } + + const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, { + method: "POST", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + webhook: { + topic, + address: webhookAddress, + format: "json" + } + }) + }) + + if (response.ok) { + logger.info(`Registered webhook: ${topic} for store ${store.id}`) + } else { + const errorData = await response.json().catch(() => ({})) + // Handle "already taken" error gracefully - webhook exists and will work + if (errorData.errors?.address?.some((msg: string) => msg.includes("already been taken"))) { + logger.info(`Webhook ${topic} already registered for address ${webhookAddress}, continuing`) + } else { + logger.error(`Failed to register webhook ${topic}`, errorData) + } + } + } catch (err) { + logger.error(`Error registering webhook ${topic}`, err) + } + } + + return NextResponse.json({ message: "Store onboarded successfully", store }, { status: 200 }) + } catch (error) { + console.error("Onboarding error:", error) + return NextResponse.json({ message: "Internal server error" }, { status: 500 }) + } +} diff --git a/src/app/api/products/sync/[syncId]/route.ts b/src/app/api/products/sync/[syncId]/route.ts new file mode 100644 index 0000000..b1bb342 --- /dev/null +++ b/src/app/api/products/sync/[syncId]/route.ts @@ -0,0 +1,196 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * PUT /api/products/sync/[syncId] + * Accept or reject a product sync request + */ +export async function PUT( + req: Request, + { params }: { params: Promise<{ syncId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { syncId } = await params + const body = await req.json() + const { action } = body // "accept" or "reject" + + if (!action || !["accept", "reject"].includes(action)) { + return NextResponse.json( + { message: "Invalid action. Use 'accept' or 'reject'" }, + { status: 400 } + ) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Find sync request + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const productSync = await (prisma as any).productSync.findUnique({ + where: { id: syncId }, + include: { + store: { + select: { + id: true, + businessName: true, + userId: true + } + } + } + }) + + if (!productSync) { + return NextResponse.json( + { message: "Product sync request not found" }, + { status: 404 } + ) + } + + // Verify sync belongs to this supplier + if (productSync.supplierId !== supplierProfile.id) { + return NextResponse.json( + { message: "Unauthorized - this sync request is not for you" }, + { status: 403 } + ) + } + + // Verify sync is pending + if (productSync.status !== "PENDING") { + return NextResponse.json( + { message: `This sync request has already been ${productSync.status.toLowerCase()}` }, + { status: 400 } + ) + } + + // Update sync status + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateData: any = { + status: action === "accept" ? "ACCEPTED" : "REJECTED" + } + + if (action === "accept") { + updateData.acceptedAt = new Date() + } else { + updateData.rejectedAt = new Date() + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (prisma as any).productSync.update({ + where: { id: syncId }, + data: updateData + }) + + // Create notification for vendor + try { + const { notifyProductSyncStatusChanged } = await import("@/lib/notifications") + await notifyProductSyncStatusChanged( + productSync.store.userId, + supplierProfile.companyName, + productSync.shopifyProductTitle, + action === "accept" ? "ACCEPTED" : "REJECTED", + productSync.id + ) + } catch (notifError) { + console.error("Error creating notification:", notifError) + // Don't fail the request if notification fails + } + + return NextResponse.json({ + message: `Product sync request ${action === "accept" ? "accepted" : "rejected"} successfully`, + sync: { + ...productSync, + status: action === "accept" ? "ACCEPTED" : "REJECTED" + } + }) + } catch (error) { + console.error("Error updating product sync:", error) + return NextResponse.json( + { message: "Failed to update product sync" }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/products/sync/[syncId] + * Cancel/delete a product sync request + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ syncId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { syncId } = await params + + // Find sync request + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const productSync = await (prisma as any).productSync.findUnique({ + where: { id: syncId }, + include: { + store: { + select: { + userId: true + } + } + } + }) + + if (!productSync) { + return NextResponse.json( + { message: "Product sync request not found" }, + { status: 404 } + ) + } + + // Verify user owns the sync (vendor who requested or supplier who received) + const isVendor = productSync.store.userId === session.user.id + const isSupplier = session.user.role === "SUPPLIER" + + if (!isVendor && !isSupplier) { + return NextResponse.json( + { message: "Unauthorized" }, + { status: 403 } + ) + } + + // Only vendor can delete pending requests, supplier should reject instead + if (isSupplier && productSync.status === "PENDING") { + return NextResponse.json( + { message: "Please reject the request instead of deleting it" }, + { status: 400 } + ) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (prisma as any).productSync.delete({ + where: { id: syncId } + }) + + return NextResponse.json({ message: "Product sync request deleted successfully" }) + } catch (error) { + console.error("Error deleting product sync:", error) + return NextResponse.json( + { message: "Failed to delete product sync" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/products/sync/route.ts b/src/app/api/products/sync/route.ts new file mode 100644 index 0000000..cae05c9 --- /dev/null +++ b/src/app/api/products/sync/route.ts @@ -0,0 +1,199 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/products/sync + * Get product sync status for vendor's products + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get vendor's active store + const vendorStore = await prisma.store.findFirst({ + where: { + userId: session.user.id, + isActive: true + } + }) + + if (!vendorStore) { + return NextResponse.json({ syncs: [] }) + } + + // Get all product syncs for this store + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncs = await (prisma as any).productSync.findMany({ + where: { + storeId: vendorStore.id + }, + include: { + supplier: { + select: { + id: true, + companyName: true + } + } + }, + orderBy: { + createdAt: "desc" + } + }) + + return NextResponse.json({ syncs }) + } catch (error) { + console.error("Error fetching product syncs:", error) + return NextResponse.json( + { message: "Failed to fetch product syncs" }, + { status: 500 } + ) + } +} + +/** + * POST /api/products/sync + * Create a product sync request + */ +export async function POST(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const body = await req.json() + const { shopifyProductId, shopifyProductTitle, shopifyProductData, supplierId } = body + + if (!shopifyProductId || !supplierId || !shopifyProductTitle) { + return NextResponse.json( + { message: "Product ID, title, and supplier ID are required" }, + { status: 400 } + ) + } + + // Get vendor's active store + const vendorStore = await prisma.store.findFirst({ + where: { + userId: session.user.id, + isActive: true + } + }) + + if (!vendorStore) { + return NextResponse.json( + { message: "No active store found" }, + { status: 400 } + ) + } + + // Verify supplier exists + const supplier = await prisma.supplierProfile.findUnique({ + where: { id: supplierId }, + include: { + user: { + select: { + id: true + } + } + } + }) + + if (!supplier) { + return NextResponse.json( + { message: "Supplier not found" }, + { status: 404 } + ) + } + + // Check if connection exists between vendor and supplier + const connection = await prisma.connection.findUnique({ + where: { + storeId_supplierId: { + storeId: vendorStore.id, + supplierId: supplier.id + } + } + }) + + if (!connection || connection.status !== "CONNECTED") { + return NextResponse.json( + { message: "You must be connected with this supplier before syncing products" }, + { status: 400 } + ) + } + + // Check if sync already exists + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const existingSync = await (prisma as any).productSync.findUnique({ + where: { + storeId_supplierId_shopifyProductId: { + storeId: vendorStore.id, + supplierId: supplier.id, + shopifyProductId: shopifyProductId.toString() + } + } + }) + + if (existingSync) { + if (existingSync.status === "ACCEPTED") { + return NextResponse.json( + { message: "This product is already synced with this supplier" }, + { status: 400 } + ) + } + if (existingSync.status === "PENDING") { + return NextResponse.json( + { message: "A sync request for this product is already pending" }, + { status: 400 } + ) + } + // If rejected, allow creating a new request + } + + // Create sync request + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const productSync = await (prisma as any).productSync.create({ + data: { + storeId: vendorStore.id, + supplierId: supplier.id, + shopifyProductId: shopifyProductId.toString(), + shopifyProductTitle, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shopifyProductData: shopifyProductData ? (shopifyProductData as any) : undefined, + status: "PENDING", + requestedBy: session.user.id + } + }) + + // Create notification for supplier + try { + const { notifyProductSyncRequested } = await import("@/lib/notifications") + await notifyProductSyncRequested( + supplier.user.id, + vendorStore.businessName, + shopifyProductTitle, + productSync.id + ) + } catch (notifError) { + console.error("Error creating notification:", notifError) + // Don't fail the request if notification fails + } + + return NextResponse.json({ + message: "Product sync request sent successfully", + sync: productSync + }) + } catch (error) { + console.error("Error creating product sync:", error) + return NextResponse.json( + { message: "Failed to create product sync request" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts new file mode 100644 index 0000000..0e88aad --- /dev/null +++ b/src/app/api/settings/account/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import bcrypt from "bcryptjs" +import { accountUpdateSchema } from "@/lib/validation" + +export async function PUT(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const body = await req.json() + const parseResult = accountUpdateSchema.safeParse(body) + + if (!parseResult.success) { + const firstIssue = parseResult.error.issues[0] + const field = firstIssue?.path?.[0] + const message = firstIssue?.message || "Invalid account data." + return NextResponse.json( + { message, field }, + { status: 400 } + ) + } + + const { businessName, currentPassword, newPassword } = parseResult.data + + // Update business name in active store + if (businessName) { + const activeStore = await prisma.store.findFirst({ + where: { userId: session.user.id, isActive: true } + }) + + if (activeStore) { + await prisma.store.update({ + where: { id: activeStore.id }, + data: { businessName } + }) + } else { + // If no active store, update user name + await prisma.user.update({ + where: { id: session.user.id }, + data: { name: businessName } + }) + } + } + + // Update password if provided + if (newPassword && currentPassword) { + const user = await prisma.user.findUnique({ + where: { id: session.user.id } + }) + + if (!user || !user.password) { + return NextResponse.json( + { message: "Password change not available for this account" }, + { status: 400 } + ) + } + + const isValid = await bcrypt.compare(currentPassword, user.password) + if (!isValid) { + return NextResponse.json( + { message: "Current password is incorrect" }, + { status: 400 } + ) + } + + const hashedPassword = await bcrypt.hash(newPassword, 10) + await prisma.user.update({ + where: { id: session.user.id }, + data: { password: hashedPassword } + }) + } + + return NextResponse.json({ message: "Account updated successfully" }) + } catch (error) { + console.error("Account update error:", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/mfa/setup/route.ts b/src/app/api/settings/mfa/setup/route.ts new file mode 100644 index 0000000..bd0fc1f --- /dev/null +++ b/src/app/api/settings/mfa/setup/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { generateSecret, generateURI } from "otplib" +import { toDataURL } from "qrcode" +import { encryptString } from "@/lib/encryption" +import { logger } from "@/lib/logger" + +export async function POST() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id } + }) + + if (!user) { + return NextResponse.json({ message: "User not found" }, { status: 404 }) + } + + // Generate a new secret + const secret = generateSecret() + + // Create service name and account name for the QR code + const serviceName = process.env.MFA_ISSUER_NAME || "JUNOHUB" + const accountName = user.email || user.name || "User" + + // Generate TOTP URI + const otpAuthUrl = generateURI({ + secret, + label: accountName, + issuer: serviceName + }) + + // Generate QR code as data URL + let qrCodeDataUrl: string + try { + qrCodeDataUrl = await toDataURL(otpAuthUrl) + } catch (qrError) { + logger.error("QR Code generation error", qrError) + throw new Error(`Failed to generate QR code: ${qrError instanceof Error ? qrError.message : "Unknown error"}`) + } + + // Store the secret temporarily (not enabled yet - will be enabled after verification) + // Encrypt the secret at rest + const encryptedSecret = encryptString(secret) + await prisma.$executeRawUnsafe( + `UPDATE "User" SET "mfaSecret" = $1 WHERE id = $2`, + encryptedSecret, + session.user.id + ) + + return NextResponse.json({ + secret, + qrCode: qrCodeDataUrl, + manualEntryKey: secret // For manual entry if QR code can't be scanned + }) + } catch (error) { + logger.error("MFA setup error", error) + const errorMessage = error instanceof Error ? error.message : "Unknown error" + const errorStack = error instanceof Error ? error.stack : undefined + logger.error("MFA setup error details", { errorMessage, errorStack }) + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/mfa/status/route.ts b/src/app/api/settings/mfa/status/route.ts new file mode 100644 index 0000000..4962d66 --- /dev/null +++ b/src/app/api/settings/mfa/status/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { decryptString } from "@/lib/encryption" +import { logger } from "@/lib/logger" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Use raw query to access MFA fields until Prisma client is fully regenerated + const result = await prisma.$queryRawUnsafe>( + `SELECT "mfaEnabled", "mfaSecret" FROM "User" WHERE id = $1`, + session.user.id + ) + + if (!result || result.length === 0) { + return NextResponse.json({ message: "User not found" }, { status: 404 }) + } + + const user = result[0] + const hasSecret = !!decryptString(user.mfaSecret) + return NextResponse.json({ + enabled: user.mfaEnabled ?? false, + hasSecret + }) + } catch (error) { + logger.error("MFA status error", error) + const errorMessage = error instanceof Error ? error.message : "Unknown error" + const errorStack = error instanceof Error ? error.stack : undefined + logger.error("MFA status error details", { errorMessage, errorStack }) + return NextResponse.json( + { message: "Internal server error", error: errorMessage }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/mfa/toggle/route.ts b/src/app/api/settings/mfa/toggle/route.ts new file mode 100644 index 0000000..8d5b629 --- /dev/null +++ b/src/app/api/settings/mfa/toggle/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { decryptString } from "@/lib/encryption" + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { enabled } = await req.json() + + // Use raw query to check mfaSecret until Prisma client is fully regenerated + const result = await prisma.$queryRawUnsafe>( + `SELECT "mfaSecret" FROM "User" WHERE id = $1`, + session.user.id + ) + + if (!result || result.length === 0) { + return NextResponse.json({ message: "User not found" }, { status: 404 }) + } + + const storedSecret = result[0].mfaSecret + const mfaSecret = decryptString(storedSecret) + if (enabled && !mfaSecret) { + return NextResponse.json( + { message: "Please set up MFA first by generating a secret and verifying it." }, + { status: 400 } + ) + } + + // Disable MFA (can be done without verification) + if (!enabled) { + await prisma.$executeRawUnsafe( + `UPDATE "User" SET "mfaEnabled" = false WHERE id = $1`, + session.user.id + ) + + return NextResponse.json({ + message: "MFA disabled successfully", + enabled: false + }) + } + + // Enabling requires verification (handled by verify endpoint) + return NextResponse.json( + { message: "Please verify your code first using the verify endpoint" }, + { status: 400 } + ) + } catch (error) { + console.error("MFA toggle error:", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/mfa/verify/route.ts b/src/app/api/settings/mfa/verify/route.ts new file mode 100644 index 0000000..4ee66e9 --- /dev/null +++ b/src/app/api/settings/mfa/verify/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { verify } from "otplib" +import { decryptString } from "@/lib/encryption" +import { applyRateLimit } from "@/lib/rate-limit" + +export async function POST(req: Request) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { code } = await req.json() + + // Rate limit MFA setup verification attempts per IP + const rate = await applyRateLimit(req, "settings:mfa-verify") + if (!rate.success) { + return NextResponse.json( + { message: "Too many verification attempts. Please try again later." }, + { status: 429 } + ) + } + + if (!code || typeof code !== "string") { + return NextResponse.json( + { message: "Verification code is required" }, + { status: 400 } + ) + } + + // Use raw query to access mfaSecret until Prisma client is fully regenerated + const userResult = await prisma.$queryRawUnsafe>( + `SELECT "mfaSecret" FROM "User" WHERE id = $1`, + session.user.id + ) + + if (!userResult || userResult.length === 0) { + return NextResponse.json( + { message: "User not found" }, + { status: 404 } + ) + } + + const storedSecret = userResult[0].mfaSecret + const mfaSecret = decryptString(storedSecret) + if (!mfaSecret) { + return NextResponse.json( + { message: "MFA secret not found. Please set up MFA first." }, + { status: 400 } + ) + } + + // Verify the TOTP code + const verifyResult = await verify({ token: code, secret: mfaSecret }) + const isValid = verifyResult.valid ?? false + + if (!isValid) { + return NextResponse.json( + { message: "Invalid verification code" }, + { status: 400 } + ) + } + + // Enable MFA + await prisma.$executeRawUnsafe( + `UPDATE "User" SET "mfaEnabled" = true WHERE id = $1`, + session.user.id + ) + + return NextResponse.json({ + message: "MFA enabled successfully", + enabled: true + }) + } catch (error) { + console.error("MFA verify error:", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/sessions/[sessionId]/route.ts b/src/app/api/settings/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..4b676c9 --- /dev/null +++ b/src/app/api/settings/sessions/[sessionId]/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ sessionId: string }> } +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { sessionId } = await params + + // Verify the session belongs to the user + const userSession = await prisma.session.findFirst({ + where: { + id: sessionId, + userId: session.user.id + } + }) + + if (!userSession) { + return NextResponse.json({ message: "Session not found" }, { status: 404 }) + } + + // Delete the session + await prisma.session.delete({ + where: { id: sessionId } + }) + + return NextResponse.json({ message: "Session revoked successfully" }) + } catch (error) { + console.error("Revoke session error:", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/settings/sessions/route.ts b/src/app/api/settings/sessions/route.ts new file mode 100644 index 0000000..9cff567 --- /dev/null +++ b/src/app/api/settings/sessions/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { cookies } from "next/headers" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get current session token from cookies + const cookieStore = await cookies() + const sessionToken = cookieStore.get("authjs.session-token")?.value || + cookieStore.get("__Secure-authjs.session-token")?.value + + // Fetch all sessions for the user + const userSessions = await prisma.session.findMany({ + where: { userId: session.user.id }, + orderBy: { expires: "desc" } + }) + + // Transform sessions to include additional info + const sessions = userSessions.map((s) => ({ + id: s.id, + sessionToken: s.sessionToken, + expires: s.expires.toISOString(), + isCurrent: s.sessionToken === sessionToken + })) + + return NextResponse.json({ sessions }) + } catch (error) { + console.error("Fetch sessions error:", error) + return NextResponse.json( + { message: "Internal server error" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/shopify/customers/route.ts b/src/app/api/shopify/customers/route.ts new file mode 100644 index 0000000..53fc423 --- /dev/null +++ b/src/app/api/shopify/customers/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { isCacheFresh, getCachedCustomers, updateCustomerCache } from "@/lib/cache" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const store = await prisma.store.findFirst({ + where: { userId: session.user.id, isActive: true } + }) + + if (!store || !store.shopifyAccessToken || !store.shopifyDomain) { + return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 }) + } + + // Clean domain to remove any whitespace or invalid characters + const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + if (!cleanDomain) { + return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 }) + } + + // Check if cache is fresh + const cacheFresh = await isCacheFresh(store.id, "customers") + + if (cacheFresh) { + // Return cached data transformed to match Shopify format + const cachedCustomers = await getCachedCustomers(store.id) + const transformedCustomers = cachedCustomers.map(customer => ({ + id: parseInt(customer.shopifyCustomerId), + email: customer.email, + first_name: customer.firstName, + last_name: customer.lastName, + orders_count: customer.ordersCount, + total_spent: customer.totalSpent, + phone: customer.phone, + tags: customer.tags, + created_at: customer.shopifyCreatedAt?.toISOString() || customer.createdAt.toISOString() + })) + return NextResponse.json({ + customers: transformedCustomers, + cached: true, + count: transformedCustomers.length + }) + } + + // Fetch customers from Shopify with timeout and limit + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout + + try { + const response = await fetch( + `https://${cleanDomain}/admin/api/2024-01/customers.json?limit=50`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken, + "Content-Type": "application/json" + }, + signal: controller.signal + } + ) + clearTimeout(timeoutId) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: "Unknown error" })) + console.error("Shopify API error:", { status: response.status, error: errorData, storeId: store.id }) + return NextResponse.json({ + message: "Failed to fetch customers from Shopify.", + details: errorData + }, { status: response.status }) + } + + const data = await response.json() + + // Update cache in background + if (data.customers) { + await updateCustomerCache(store.id, data.customers).catch(err => { + console.error("Cache update error:", err) + }) + } + + return NextResponse.json({ ...data, cached: false }) + } catch (fetchError: unknown) { + clearTimeout(timeoutId) + if (fetchError instanceof Error && fetchError.name === "AbortError") { + return NextResponse.json({ + message: "Request timeout. Shopify API took too long to respond.", + error: "TIMEOUT" + }, { status: 504 }) + } + throw fetchError + } + + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error" + console.error("Fetch customers error:", { message, error }) + return NextResponse.json({ + message: "Internal server error while fetching customers.", + error: message + }, { status: 500 }) + } +} diff --git a/src/app/api/shopify/inventory/route.ts b/src/app/api/shopify/inventory/route.ts new file mode 100644 index 0000000..cc3c1d5 --- /dev/null +++ b/src/app/api/shopify/inventory/route.ts @@ -0,0 +1,110 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { isCacheFresh, getCachedProducts, updateProductCache } from "@/lib/cache" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const store = await prisma.store.findFirst({ + where: { userId: session.user.id, isActive: true } + }) + + if (!store || !store.shopifyAccessToken || !store.shopifyDomain) { + return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 }) + } + + // Clean domain to remove any whitespace or invalid characters + const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + if (!cleanDomain) { + return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 }) + } + + // Check if cache is fresh + const cacheFresh = await isCacheFresh(store.id, "products") + + if (cacheFresh) { + // Return cached data transformed to match Shopify format + const cachedProducts = await getCachedProducts(store.id) + const transformedProducts = cachedProducts.map(product => { + const images = (product.images as { src: string }[]) || [] + return { + id: parseInt(product.shopifyProductId), + title: product.title, + vendor: product.vendor, + product_type: product.productType, + status: product.status, + variants: product.variants, + images: images, + image: images.length > 0 ? { src: images[0].src } : null + } + }) + return NextResponse.json({ + products: transformedProducts, + cached: true, + count: transformedProducts.length + }) + } + + // Fetch fresh data from Shopify with timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout + + try { + const response = await fetch( + `https://${cleanDomain}/admin/api/2024-01/products.json?limit=250`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken, + "Content-Type": "application/json" + }, + signal: controller.signal + } + ) + clearTimeout(timeoutId) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: "Unknown error" })) + console.error("Shopify API error:", { status: response.status, error: errorData, storeId: store.id }) + return NextResponse.json({ + message: "Failed to fetch products from Shopify.", + details: errorData + }, { status: response.status }) + } + + const data = await response.json() + + // Update cache in background + if (data.products) { + await updateProductCache(store.id, data.products).catch(err => { + console.error("Cache update error:", err) + }) + } + + return NextResponse.json({ ...data, cached: false }) + } catch (fetchError: unknown) { + clearTimeout(timeoutId) + if (fetchError instanceof Error && fetchError.name === "AbortError") { + return NextResponse.json({ + message: "Request timeout. Shopify API took too long to respond.", + error: "TIMEOUT" + }, { status: 504 }) + } + throw fetchError + } + + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error" + console.error("Fetch inventory error:", { message, error, stack: error instanceof Error ? error.stack : undefined }) + return NextResponse.json({ + message: "Internal server error while fetching inventory.", + error: message + }, { status: 500 }) + } +} diff --git a/src/app/api/shopify/orders/[orderId]/route.ts b/src/app/api/shopify/orders/[orderId]/route.ts new file mode 100644 index 0000000..3ea1ced --- /dev/null +++ b/src/app/api/shopify/orders/[orderId]/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { handleApiError } from "@/lib/errors" + +export async function GET( + request: Request, + { params }: { params: Promise<{ orderId: string }> } +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { orderId } = await params + + const store = await prisma.store.findFirst({ + where: { userId: session.user.id, isActive: true } + }) + + if (!store || !store.shopifyAccessToken || !store.shopifyDomain) { + return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 }) + } + + const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + if (!cleanDomain) { + return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 }) + } + + // Fetch order from Shopify + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) + + try { + const response = await fetch( + `https://${cleanDomain}/admin/api/2024-01/orders/${orderId}.json`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken, + "Content-Type": "application/json" + }, + signal: controller.signal + } + ) + clearTimeout(timeoutId) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: "Unknown error" })) + return NextResponse.json({ + message: "Failed to fetch order from Shopify.", + details: errorData + }, { status: response.status }) + } + + const data = await response.json() + return NextResponse.json({ order: data.order }) + } catch (fetchError: unknown) { + clearTimeout(timeoutId) + if (fetchError instanceof Error && fetchError.name === "AbortError") { + return NextResponse.json({ + message: "Request timeout.", + error: "TIMEOUT" + }, { status: 504 }) + } + throw fetchError + } + + } catch (error: unknown) { + return handleApiError(error, "fetch-order") + } +} diff --git a/src/app/api/shopify/orders/route.ts b/src/app/api/shopify/orders/route.ts new file mode 100644 index 0000000..7fc6771 --- /dev/null +++ b/src/app/api/shopify/orders/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { isCacheFresh, getCachedOrders, updateOrderCache } from "@/lib/cache" + +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const store = await prisma.store.findFirst({ + where: { userId: session.user.id, isActive: true } + }) + + if (!store || !store.shopifyAccessToken || !store.shopifyDomain) { + return NextResponse.json({ message: "Shopify store not connected." }, { status: 400 }) + } + + // Clean domain to remove any whitespace or invalid characters + const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + if (!cleanDomain) { + return NextResponse.json({ message: "Invalid Shopify domain configured." }, { status: 400 }) + } + + // Check if cache is fresh + const cacheFresh = await isCacheFresh(store.id, "orders") + + if (cacheFresh) { + // Return cached data transformed to match Shopify format + const cachedOrders = await getCachedOrders(store.id) + const transformedOrders = cachedOrders.map(order => ({ + id: parseInt(order.shopifyOrderId), + name: order.orderNumber, + email: order.email, + created_at: order.shopifyCreatedAt?.toISOString() || order.createdAt.toISOString(), + total_price: order.totalPrice, + currency: order.currency, + financial_status: order.financialStatus, + fulfillment_status: order.fulfillmentStatus, + customer: order.customer, + line_items: order.lineItems + })) + return NextResponse.json({ + orders: transformedOrders, + cached: true, + count: transformedOrders.length + }) + } + + // Fetch orders from Shopify with timeout and limit + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout + + try { + const response = await fetch( + `https://${cleanDomain}/admin/api/2024-01/orders.json?status=any&limit=50`, + { + method: "GET", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken, + "Content-Type": "application/json" + }, + signal: controller.signal + } + ) + clearTimeout(timeoutId) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: "Unknown error" })) + console.error("Shopify API error:", { status: response.status, error: errorData, storeId: store.id }) + return NextResponse.json({ + message: "Failed to fetch orders from Shopify.", + details: errorData + }, { status: response.status }) + } + + const data = await response.json() + + // Update cache in background + if (data.orders) { + await updateOrderCache(store.id, data.orders).catch(err => { + console.error("Cache update error:", err) + }) + } + + return NextResponse.json({ ...data, cached: false }) + } catch (fetchError: unknown) { + clearTimeout(timeoutId) + if (fetchError instanceof Error && fetchError.name === "AbortError") { + return NextResponse.json({ + message: "Request timeout. Shopify API took too long to respond.", + error: "TIMEOUT" + }, { status: 504 }) + } + throw fetchError + } + + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error" + console.error("Fetch orders error:", { message, error, stack: error instanceof Error ? error.stack : undefined }) + return NextResponse.json({ + message: "Internal server error while fetching orders.", + error: message + }, { status: 500 }) + } +} diff --git a/src/app/api/shopify/validate/route.ts b/src/app/api/shopify/validate/route.ts new file mode 100644 index 0000000..58b747a --- /dev/null +++ b/src/app/api/shopify/validate/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" + +export async function POST(req: Request) { + try { + const body = await req.json() + const { domain, accessToken } = body + + if (!domain || !accessToken) { + return NextResponse.json({ message: "Store domain and access token are required." }, { status: 400 }) + } + + // Clean domain: remove protocol, trailing slashes, and trim whitespace + const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + // Create an AbortController for timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) + + try { + // Shopify Shop API endpoint + const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/shop.json`, { + method: "GET", + headers: { + "X-Shopify-Access-Token": accessToken, + "Content-Type": "application/json" + }, + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + return NextResponse.json({ + message: "Invalid credentials. Please verify your domain and access token.", + details: errorData + }, { status: response.status }) + } + + const data = await response.json() + return NextResponse.json({ + message: "Validation successful!", + shop: data.shop + }) + + } catch (fetchError: unknown) { + clearTimeout(timeoutId) + if (fetchError instanceof Error && fetchError.name === "AbortError") { + return NextResponse.json({ message: "Connection to Shopify timed out. Please check the domain." }, { status: 408 }) + } + throw fetchError + } + + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error" + console.error("Shopify validation error:", error) + return NextResponse.json({ + message: "Failed to connect to Shopify. Verify the domain is correct.", + error: message + }, { status: 500 }) + } +} diff --git a/src/app/api/stores/[storeId]/kb/documents/route.ts b/src/app/api/stores/[storeId]/kb/documents/route.ts new file mode 100644 index 0000000..16c5d23 --- /dev/null +++ b/src/app/api/stores/[storeId]/kb/documents/route.ts @@ -0,0 +1,298 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { processRawDocument } from "@/lib/kb/documentProcessing" +import { indexDocumentChunks } from "@/lib/kb/indexing" +import path from "path" +import fs from "fs/promises" +import { randomUUID } from "crypto" + +const UPLOAD_DIR = process.env.KB_UPLOAD_DIR || "uploads/kb" + +async function ensureUploadDir() { + const absolute = path.resolve(UPLOAD_DIR) + await fs.mkdir(absolute, { recursive: true }) + return absolute +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const formData = await request.formData() + const file = formData.get("file") + + if (!(file instanceof File)) { + return NextResponse.json( + { message: "Missing file field 'file'" }, + { status: 400 }, + ) + } + + const mimeType = file.type || "application/octet-stream" + const originalName = file.name || "document" + + if ( + ![ + "application/pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/msword", + ].includes(mimeType) + ) { + return NextResponse.json( + { message: "Only PDF and DOCX files are supported" }, + { status: 400 }, + ) + } + + const uploadDir = await ensureUploadDir() + const id = randomUUID() + const fileNameOnDisk = `${id}-${originalName}` + const storagePath = path.join(uploadDir, fileNameOnDisk) + + const arrayBuffer = await file.arrayBuffer() + await fs.writeFile(storagePath, Buffer.from(arrayBuffer)) + + const sizeBytes = Buffer.byteLength(Buffer.from(arrayBuffer)) + + // Insert document via raw SQL to avoid relying on generated model delegates + await prisma.$executeRawUnsafe( + ` + INSERT INTO "KnowledgeDocument" + ("id", "storeId", "title", "fileName", "mimeType", "sizeBytes", "status", "errorMessage", "storagePath", "createdAt", "updatedAt") + VALUES ($1, $2, $3, $4, $5, $6, 'PROCESSING', NULL, $7, NOW(), NOW()) + `, + id, + store.id, + originalName, + originalName, + mimeType, + sizeBytes, + storagePath, + ) + + const [document] = (await prisma.$queryRawUnsafe< + { + id: string + storeId: string + title: string + fileName: string + mimeType: string + sizeBytes: number + status: string + storagePath: string + }[] + >( + ` + SELECT "id", "storeId", "title", "fileName", "mimeType", "sizeBytes", "status", "storagePath" + FROM "KnowledgeDocument" + WHERE "id" = $1 + `, + id, + )) ?? [] + + // Process and index inline for now; can be moved to a background job later + try { + const chunks = await processRawDocument({ + id: document.id, + storeId: document.storeId, + storagePath: document.storagePath, + mimeType: document.mimeType, + }) + + await indexDocumentChunks(chunks) + + await prisma.$executeRawUnsafe( + ` + UPDATE "KnowledgeDocument" + SET "status" = 'READY', + "errorMessage" = NULL, + "updatedAt" = NOW() + WHERE "id" = $1 + `, + document.id, + ) + } catch (error) { + logger.error("Failed to process knowledge document", error) + await prisma.$executeRawUnsafe( + ` + UPDATE "KnowledgeDocument" + SET "status" = 'FAILED', + "errorMessage" = $2, + "updatedAt" = NOW() + WHERE "id" = $1 + `, + document.id, + error instanceof Error ? error.message : "Unknown error", + ) + } + + return NextResponse.json( + { + success: true, + document: { + id: document.id, + title: document.title, + status: document.status, + }, + }, + { status: 201 }, + ) + } catch (error) { + logger.error("Error uploading knowledge base document", error) + return NextResponse.json( + { message: "Failed to upload document" }, + { status: 500 }, + ) + } +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const documents = await prisma.$queryRawUnsafe< + { + id: string + title: string + fileName: string + mimeType: string + sizeBytes: number + status: string + createdAt: Date + updatedAt: Date + }[] + >( + ` + SELECT "id", "title", "fileName", "mimeType", "sizeBytes", "status", "createdAt", "updatedAt" + FROM "KnowledgeDocument" + WHERE "storeId" = $1 + ORDER BY "createdAt" DESC + `, + store.id, + ) + + return NextResponse.json({ documents }) + } catch (error) { + logger.error("Error listing knowledge base documents", error) + return NextResponse.json( + { message: "Failed to list documents" }, + { status: 500 }, + ) + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + const { searchParams } = new URL(request.url) + const documentId = searchParams.get("id") + + if (!documentId) { + return NextResponse.json( + { message: "Document ID is required" }, + { status: 400 }, + ) + } + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + // Ensure the document belongs to this store + const [doc] = (await prisma.$queryRawUnsafe< + { id: string; storeId: string }[] + >( + ` + SELECT "id", "storeId" + FROM "KnowledgeDocument" + WHERE "id" = $1 AND "storeId" = $2 + `, + documentId, + store.id, + )) ?? [] + + if (!doc) { + return NextResponse.json( + { message: "Document not found" }, + { status: 404 }, + ) + } + + // Delete associated chunks first, then the document + await prisma.$executeRawUnsafe( + `DELETE FROM "KnowledgeChunk" WHERE "documentId" = $1`, + documentId, + ) + await prisma.$executeRawUnsafe( + `DELETE FROM "KnowledgeDocument" WHERE "id" = $1`, + documentId, + ) + + return NextResponse.json({ success: true }) + } catch (error) { + logger.error("Error deleting knowledge base document", error) + return NextResponse.json( + { message: "Failed to delete document" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/stores/[storeId]/kb/faqs/import/route.ts b/src/app/api/stores/[storeId]/kb/faqs/import/route.ts new file mode 100644 index 0000000..83f6946 --- /dev/null +++ b/src/app/api/stores/[storeId]/kb/faqs/import/route.ts @@ -0,0 +1,154 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { embedBatch } from "@/lib/kb/geminiEmbeddings" + +type FaqImportItem = { + question: string + answer: string + tags?: unknown +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + let items: FaqImportItem[] = [] + + const contentType = request.headers.get("content-type") || "" + if (contentType.includes("application/json")) { + const body = await request.json() + if (!Array.isArray(body)) { + return NextResponse.json( + { message: "JSON body must be an array of FAQs" }, + { status: 400 }, + ) + } + items = body as FaqImportItem[] + } else if (contentType.includes("multipart/form-data")) { + const formData = await request.formData() + const file = formData.get("file") + if (!(file instanceof File)) { + return NextResponse.json( + { message: "Missing file field 'file'" }, + { status: 400 }, + ) + } + const text = await file.text() + try { + const parsed = JSON.parse(text) + if (!Array.isArray(parsed)) { + return NextResponse.json( + { message: "JSON must be an array of FAQs" }, + { status: 400 }, + ) + } + items = parsed as FaqImportItem[] + } catch { + return NextResponse.json( + { message: "Invalid JSON in uploaded file" }, + { status: 400 }, + ) + } + } else { + return NextResponse.json( + { message: "Unsupported content type" }, + { status: 400 }, + ) + } + + const validItems = items.filter( + (item) => item.question && item.answer, + ) as FaqImportItem[] + + if (!validItems.length) { + return NextResponse.json( + { message: "No valid FAQs to import" }, + { status: 400 }, + ) + } + + const createdFaqs = await prisma.$transaction( + validItems.map((item) => + prisma.faqItem.create({ + data: { + storeId: store.id, + question: item.question, + answer: item.answer, + tags: item.tags ?? undefined, + }, + }), + ), + ) + + const texts = createdFaqs.map( + (faq) => `${faq.question}\n${faq.answer}`, + ) + const embeddings = await embedBatch(texts) + + await Promise.all( + createdFaqs.map((faq, index) => { + const embedding = embeddings[index] + if (!embedding || !embedding.length) return Promise.resolve() + + return prisma.$executeRawUnsafe( + ` + INSERT INTO "KnowledgeChunk" + ("id", "storeId", "faqItemId", "sourceType", "content", "metadata", "embedding", "createdAt", "updatedAt") + VALUES ( + gen_random_uuid()::text, + $1, + $2, + 'FAQ', + $3, + $4, + '[${embedding.join(",")}]', + NOW(), + NOW() + ) + `, + faq.storeId, + faq.id, + `${faq.question}\n${faq.answer}`, + { + question: faq.question, + answer: faq.answer, + } as unknown as object, + ) + }), + ) + + return NextResponse.json( + { imported: createdFaqs.length }, + { status: 201 }, + ) + } catch (error) { + logger.error("Error importing FAQs", error) + return NextResponse.json( + { message: "Failed to import FAQs" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/stores/[storeId]/kb/faqs/route.ts b/src/app/api/stores/[storeId]/kb/faqs/route.ts new file mode 100644 index 0000000..1af4f52 --- /dev/null +++ b/src/app/api/stores/[storeId]/kb/faqs/route.ts @@ -0,0 +1,131 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { embedBatch } from "@/lib/kb/geminiEmbeddings" + +export async function GET( + _request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const faqs = await prisma.faqItem.findMany({ + where: { storeId: store.id }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json({ faqs }) + } catch (error) { + logger.error("Error listing FAQs", error) + return NextResponse.json( + { message: "Failed to list FAQs" }, + { status: 500 }, + ) + } +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const body = await request.json() + const { question, answer, tags } = body as { + question?: string + answer?: string + tags?: unknown + } + + if (!question || !answer) { + return NextResponse.json( + { message: "Question and answer are required" }, + { status: 400 }, + ) + } + + const faq = await prisma.faqItem.create({ + data: { + storeId: store.id, + question, + answer, + tags: tags ?? undefined, + }, + }) + + const [embedding] = await embedBatch([`${question}\n${answer}`]) + + if (embedding && embedding.length) { + await prisma.$executeRawUnsafe( + ` + INSERT INTO "KnowledgeChunk" + ("id", "storeId", "faqItemId", "sourceType", "content", "metadata", "embedding", "createdAt", "updatedAt") + VALUES ( + gen_random_uuid()::text, + $1, + $2, + 'FAQ', + $3, + $4, + '[${embedding.join(",")}]', + NOW(), + NOW() + ) + `, + faq.storeId, + faq.id, + `${question}\n${answer}`, + { + question, + answer, + } as unknown as object, + ) + } + + return NextResponse.json({ faq }, { status: 201 }) + } catch (error) { + logger.error("Error creating FAQ", error) + return NextResponse.json( + { message: "Failed to create FAQ" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/stores/[storeId]/kb/query/route.ts b/src/app/api/stores/[storeId]/kb/query/route.ts new file mode 100644 index 0000000..ef54be7 --- /dev/null +++ b/src/app/api/stores/[storeId]/kb/query/route.ts @@ -0,0 +1,224 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { embedBatch } from "@/lib/kb/geminiEmbeddings" + +const GEMINI_API_KEY = process.env.GEMINI_API_KEY +const GEMINI_MODEL = + process.env.GEMINI_CHAT_MODEL || "models/gemini-1.5-flash-latest" + +type QueryBody = { + query?: string + topK?: number + includeFaq?: boolean + includeDocuments?: boolean +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ storeId: string }> }, +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id, + }, + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const body = (await request.json().catch(() => ({}))) as QueryBody + const question = body.query?.trim() + if (!question) { + return NextResponse.json( + { message: "Query is required" }, + { status: 400 }, + ) + } + + const topK = Math.min(Math.max(body.topK ?? 8, 1), 20) + const includeFaq = body.includeFaq ?? true + const includeDocuments = body.includeDocuments ?? true + + if (!includeFaq && !includeDocuments) { + return NextResponse.json( + { message: "At least one of FAQs or Documents must be included" }, + { status: 400 }, + ) + } + + const [embedding] = await embedBatch([question]) + if (!embedding || !embedding.length) { + return NextResponse.json( + { message: "Failed to generate embedding for query" }, + { status: 500 }, + ) + } + + const filters: string[] = ['"storeId" = $1'] + if (includeFaq && !includeDocuments) { + filters.push(`"sourceType" = 'FAQ'`) + } else if (!includeFaq && includeDocuments) { + filters.push(`"sourceType" = 'DOCUMENT'`) + } + + const whereClause = filters.length ? `WHERE ${filters.join(" AND ")}` : "" + + const chunks = await prisma.$queryRawUnsafe< + { + id: string + content: string + sourceType: "DOCUMENT" | "FAQ" + documentId: string | null + faqItemId: string | null + metadata: unknown + similarity: number + }[] + >( + ` + SELECT + "id", + "content", + "sourceType", + "documentId", + "faqItemId", + "metadata", + 1 - ("embedding" <=> '[${embedding.join(",")}]') AS similarity + FROM "KnowledgeChunk" + ${whereClause} + ORDER BY "embedding" <=> '[${embedding.join(",")}]' + LIMIT ${topK} + `, + store.id, + ) + + if (!chunks.length) { + return NextResponse.json({ + answer: + "I could not find any relevant information in this store’s knowledge base.", + sources: [], + }) + } + + const contextPieces = chunks.map((chunk, index) => { + const label = + chunk.sourceType === "FAQ" + ? `FAQ #${index + 1}` + : `Document chunk #${index + 1}` + return `${label} (similarity: ${chunk.similarity.toFixed(2)}):\n${ + chunk.content + }` + }) + + const context = contextPieces.join("\n\n---\n\n") + + if (!GEMINI_API_KEY) { + return NextResponse.json( + { + message: + "GEMINI_API_KEY is not configured, cannot generate an AI answer.", + context, + sources: chunks.map((chunk) => ({ + id: chunk.id, + type: chunk.sourceType, + documentId: chunk.documentId, + faqId: chunk.faqItemId, + similarity: chunk.similarity, + })), + }, + { status: 500 }, + ) + } + + const systemPrompt = + "You are an AI assistant helping answer customer and vendor questions for a specific store.\n" + + "Use ONLY the provided context from the store's documents and FAQs.\n" + + "If the answer is not clearly present in the context, say that you do not know.\n" + + "Be concise and helpful.\n" + + const response = await fetch( + "https://generativelanguage.googleapis.com/v1beta/models/" + + encodeURIComponent(GEMINI_MODEL) + + ":generateContent", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": GEMINI_API_KEY, + }, + body: JSON.stringify({ + model: GEMINI_MODEL, + contents: [ + { + role: "user", + parts: [ + { + text: + systemPrompt + + "\n\nContext:\n" + + context + + "\n\nQuestion:\n" + + question, + }, + ], + }, + ], + }), + }, + ) + + if (!response.ok) { + const errorText = await response.text().catch(() => "") + logger.error("Gemini RAG answer error", { + status: response.status, + body: errorText, + }) + return NextResponse.json( + { message: "Failed to generate answer from Gemini" }, + { status: 500 }, + ) + } + + const json = (await response.json()) as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> } + }> + } + + const answer = + json.candidates?.[0]?.content?.parts + ?.map((p) => p.text || "") + .join("") + .trim() || "I am unable to generate an answer at this time." + + return NextResponse.json({ + answer, + sources: chunks.map((chunk) => ({ + id: chunk.id, + type: chunk.sourceType, + documentId: chunk.documentId, + faqId: chunk.faqItemId, + similarity: chunk.similarity, + })), + }) + } catch (error) { + logger.error("Error querying knowledge base", error) + return NextResponse.json( + { message: "Failed to query knowledge base" }, + { status: 500 }, + ) + } +} + diff --git a/src/app/api/stores/[storeId]/route.ts b/src/app/api/stores/[storeId]/route.ts new file mode 100644 index 0000000..494a0b8 --- /dev/null +++ b/src/app/api/stores/[storeId]/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * DELETE /api/stores/[storeId] + * Delete a store for the authenticated user + */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ storeId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + if (!storeId) { + return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) + } + + // Verify the store belongs to the user + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id + } + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + // Check if this is the only store + const storeCount = await prisma.store.count({ + where: { userId: session.user.id } + }) + + if (storeCount === 1) { + return NextResponse.json({ + message: "Cannot delete your only store. Please add another store first." + }, { status: 400 }) + } + + // If deleting the active store, switch to another store first + if (store.isActive) { + const otherStore = await prisma.store.findFirst({ + where: { + userId: session.user.id, + id: { not: storeId } + }, + orderBy: { + createdAt: "desc" + } + }) + + if (otherStore) { + // Switch to another store + await prisma.store.updateMany({ + where: { + userId: session.user.id + }, + data: { + isActive: false + } + }) + + await prisma.store.update({ + where: { id: otherStore.id }, + data: { isActive: true } + }) + + await prisma.user.update({ + where: { id: session.user.id }, + data: { activeStoreId: otherStore.id } + }) + } + } + + // Delete the store (cascade will handle related data) + await prisma.store.delete({ + where: { id: storeId } + }) + + return NextResponse.json({ + success: true, + message: "Store deleted successfully" + }) + } catch (error) { + console.error("Error deleting store:", error) + return NextResponse.json( + { message: "Failed to delete store" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/stores/[storeId]/webhooks/route.ts b/src/app/api/stores/[storeId]/webhooks/route.ts new file mode 100644 index 0000000..ed0febe --- /dev/null +++ b/src/app/api/stores/[storeId]/webhooks/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" + +/** + * POST /api/stores/[storeId]/webhooks + * Register webhooks for a store + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ storeId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await params + + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id + } + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + const appUrl = process.env.NEXTAUTH_URL || "https://your-tunnel-url.ngrok-free.app" + const webhookTopics = ["orders/create", "orders/updated", "products/create", "products/update", "products/delete"] + const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + const webhookAddress = `${appUrl}/api/webhooks/shopify` + + const registered: string[] = [] + const failed: string[] = [] + + // First, check existing webhooks to avoid duplicates + let existingWebhooks: Array<{ topic: string; id: number; address: string }> = [] + try { + const listResponse = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, { + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken, + "Content-Type": "application/json" + } + }) + if (listResponse.ok) { + const webhooksData = await listResponse.json() + existingWebhooks = (webhooksData.webhooks || []).map((wh: { topic: string; id: number; address: string }) => ({ + topic: wh.topic, + id: wh.id, + address: wh.address + })) + } + } catch (err) { + logger.warn(`Could not fetch existing webhooks for store ${storeId}`, err) + } + + for (const topic of webhookTopics) { + try { + // Check if webhook already exists with same address + const existingWebhook = existingWebhooks.find( + (wh) => wh.topic === topic && wh.address === webhookAddress + ) + + if (existingWebhook) { + registered.push(topic) + logger.info(`Webhook ${topic} already exists for address ${webhookAddress}, skipping registration`) + continue + } + + const response = await fetch(`https://${cleanDomain}/admin/api/2024-01/webhooks.json`, { + method: "POST", + headers: { + "X-Shopify-Access-Token": store.shopifyAccessToken, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + webhook: { + topic, + address: webhookAddress, + format: "json" + } + }) + }) + + if (response.ok) { + registered.push(topic) + logger.info(`Registered webhook: ${topic} for store ${storeId}`) + } else { + const errorData = await response.json().catch(() => ({})) + // Handle "already taken" error gracefully - webhook exists and will work + if (errorData.errors?.address?.some((msg: string) => msg.includes("already been taken"))) { + registered.push(topic) + logger.info(`Webhook ${topic} already registered for address ${webhookAddress}`) + } else { + logger.error(`Failed to register webhook ${topic}`, errorData) + failed.push(topic) + } + } + } catch (err) { + logger.error(`Error registering webhook ${topic}`, err) + failed.push(topic) + } + } + + return NextResponse.json({ + success: true, + registered, + failed, + message: `Registered ${registered.length} webhook(s)${failed.length > 0 ? `, ${failed.length} failed` : ""}` + }) + } catch (error) { + logger.error("Error registering webhooks:", error) + return NextResponse.json( + { message: "Failed to register webhooks" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/stores/route.ts b/src/app/api/stores/route.ts new file mode 100644 index 0000000..054f8c1 --- /dev/null +++ b/src/app/api/stores/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/stores + * List all stores for the authenticated user + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const stores = await prisma.store.findMany({ + where: { + userId: session.user.id + }, + select: { + id: true, + businessName: true, + shopifyDomain: true, + shopifyStoreName: true, + onboardingComplete: true, + isActive: true, + createdAt: true + }, + orderBy: { + createdAt: "desc" + } + }) + + return NextResponse.json({ stores }) + } catch (error) { + console.error("Error fetching stores:", error) + return NextResponse.json( + { message: "Failed to fetch stores" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/stores/switch/route.ts b/src/app/api/stores/switch/route.ts new file mode 100644 index 0000000..ce254dd --- /dev/null +++ b/src/app/api/stores/switch/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * POST /api/stores/switch + * Switch the active store for the user + */ +export async function POST(request: Request) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { storeId } = await request.json() + + if (!storeId) { + return NextResponse.json({ message: "Store ID is required" }, { status: 400 }) + } + + // Verify the store belongs to the user + const store = await prisma.store.findFirst({ + where: { + id: storeId, + userId: session.user.id + } + }) + + if (!store) { + return NextResponse.json({ message: "Store not found" }, { status: 404 }) + } + + // Set all stores to inactive + await prisma.store.updateMany({ + where: { + userId: session.user.id + }, + data: { + isActive: false + } + }) + + // Set the selected store as active + await prisma.store.update({ + where: { + id: storeId + }, + data: { + isActive: true + } + }) + + // Update user's activeStoreId + await prisma.user.update({ + where: { + id: session.user.id + }, + data: { + activeStoreId: storeId + } + }) + + return NextResponse.json({ + success: true, + activeStoreId: storeId + }) + } catch (error) { + console.error("Error switching store:", error) + return NextResponse.json( + { message: "Failed to switch store" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/analytics/route.ts b/src/app/api/supplier/analytics/route.ts new file mode 100644 index 0000000..f6dd445 --- /dev/null +++ b/src/app/api/supplier/analytics/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/supplier/analytics + * Get analytics data for supplier + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Get counts + const [productsCount, connectedVendorsCount, pendingConnectionsCount] = await Promise.all([ + prisma.product.count({ + where: { supplierId: supplierProfile.id } + }), + prisma.connection.count({ + where: { + supplierId: supplierProfile.id, + status: "CONNECTED" + } + }), + prisma.connection.count({ + where: { + supplierId: supplierProfile.id, + status: "PENDING" + } + }) + ]) + + // Calculate average product price + const products = await prisma.product.findMany({ + where: { supplierId: supplierProfile.id }, + select: { price: true } + }) + + const averagePrice = products.length > 0 + ? products.reduce((sum, p) => sum + p.price, 0) / products.length + : 0 + + // Get recent activity (last 7 days) + const sevenDaysAgo = new Date() + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + + const recentProducts = await prisma.product.count({ + where: { + supplierId: supplierProfile.id, + createdAt: { gte: sevenDaysAgo } + } + }) + + const recentConnections = await prisma.connection.count({ + where: { + supplierId: supplierProfile.id, + createdAt: { gte: sevenDaysAgo } + } + }) + + return NextResponse.json({ + stats: { + totalProducts: productsCount, + connectedVendors: connectedVendorsCount, + pendingConnections: pendingConnectionsCount, + averageProductPrice: averagePrice, + recentProducts, + recentConnections + }, + // Placeholder for chart data - would be populated with actual order data + chartData: [] + }) + } catch (error) { + console.error("Error fetching analytics:", error) + return NextResponse.json( + { message: "Failed to fetch analytics" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/invitations/accept/route.ts b/src/app/api/supplier/invitations/accept/route.ts new file mode 100644 index 0000000..0c44dd3 --- /dev/null +++ b/src/app/api/supplier/invitations/accept/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { notifyInvitationAccepted } from "@/lib/notifications" + +/** + * POST /api/supplier/invitations/accept + * Accept an invitation using token + */ +export async function POST(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { token } = await req.json() + + if (!token || typeof token !== "string") { + return NextResponse.json( + { message: "Invitation token is required" }, + { status: 400 } + ) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Find invitation by token + // TypeScript may not recognize invitation model due to cache, but it exists at runtime + const invitation = await (prisma as any).invitation.findUnique({ + where: { token: token.toUpperCase() }, + include: { + store: { + select: { + id: true, + businessName: true, + userId: true + } + } + } + }) + + if (!invitation) { + return NextResponse.json( + { message: "Invalid invitation token" }, + { status: 404 } + ) + } + + // Security checks + if (invitation.used) { + return NextResponse.json( + { message: "This invitation has already been used" }, + { status: 400 } + ) + } + + if (new Date() > invitation.expiresAt) { + return NextResponse.json( + { message: "This invitation has expired" }, + { status: 400 } + ) + } + + if (invitation.supplierId !== supplierProfile.id) { + return NextResponse.json( + { message: "This invitation is not for your supplier account" }, + { status: 403 } + ) + } + + // Check if connection already exists + const existingConnection = await prisma.connection.findUnique({ + where: { + storeId_supplierId: { + storeId: invitation.storeId, + supplierId: supplierProfile.id + } + } + }) + + if (existingConnection) { + // Mark invitation as used even if connection exists + // TypeScript may not recognize invitation model due to cache, but it exists at runtime + await (prisma as any).invitation.update({ + where: { id: invitation.id }, + data: { used: true, usedAt: new Date() } + }) + + return NextResponse.json( + { message: `Connection already exists with status: ${existingConnection.status}` }, + { status: 400 } + ) + } + + // Create connection + const connection = await prisma.connection.create({ + data: { + storeId: invitation.storeId, + supplierId: supplierProfile.id, + status: "CONNECTED" // Auto-connect when supplier accepts via token + } + }) + + // Mark invitation as used + // TypeScript may not recognize invitation model due to cache, but it exists at runtime + await (prisma as any).invitation.update({ + where: { id: invitation.id }, + data: { + used: true, + usedAt: new Date() + } + }) + + // Create notification for vendor + try { + await notifyInvitationAccepted( + invitation.store.userId, + supplierProfile.companyName, + invitation.store.businessName, + connection.id + ) + } catch (notifError) { + console.error("Error creating notification:", notifError) + // Don't fail the request if notification fails + } + + return NextResponse.json({ + message: "Invitation accepted successfully. You are now connected!", + connection: { + id: connection.id, + status: connection.status, + vendor: { + businessName: invitation.store.businessName + } + } + }) + } catch (error) { + console.error("Error accepting invitation:", error) + return NextResponse.json( + { message: "Failed to accept invitation" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/orders/route.ts b/src/app/api/supplier/orders/route.ts new file mode 100644 index 0000000..644d940 --- /dev/null +++ b/src/app/api/supplier/orders/route.ts @@ -0,0 +1,182 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/supplier/orders + * List orders from connected vendors that contain synced products + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Get connected vendors + const connections = await prisma.connection.findMany({ + where: { + supplierId: supplierProfile.id, + status: "CONNECTED" + }, + include: { + store: { + select: { + id: true, + businessName: true, + shopifyStoreName: true + } + } + } + }) + + if (connections.length === 0) { + return NextResponse.json({ + orders: [], + connectedVendors: 0, + syncedProducts: 0 + }) + } + + // Get all accepted product syncs for this supplier + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const acceptedSyncs = await (prisma as any).productSync.findMany({ + where: { + supplierId: supplierProfile.id, + status: "ACCEPTED" + }, + select: { + shopifyProductId: true, + storeId: true, + shopifyProductTitle: true + } + }) + + if (acceptedSyncs.length === 0) { + return NextResponse.json({ + orders: [], + connectedVendors: connections.length, + syncedProducts: 0, + message: "No products synced yet. Sync products with vendors to see orders." + }) + } + + // Group syncs by storeId + const syncsByStore = new Map>() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + acceptedSyncs.forEach((sync: any) => { + if (!syncsByStore.has(sync.storeId)) { + syncsByStore.set(sync.storeId, new Set()) + } + syncsByStore.get(sync.storeId)!.add(sync.shopifyProductId) + }) + + // Get orders from all connected stores that have synced products + const storeIds = Array.from(syncsByStore.keys()) + const cachedOrders = await prisma.cachedOrder.findMany({ + where: { + storeId: { in: storeIds } + }, + include: { + store: { + select: { + id: true, + businessName: true, + shopifyStoreName: true + } + } + }, + orderBy: { + shopifyCreatedAt: "desc" + } + }) + + // Filter orders that contain synced products + const relevantOrders = cachedOrders.filter((order) => { + const syncedProductIds = syncsByStore.get(order.storeId) + if (!syncedProductIds || syncedProductIds.size === 0) return false + + // Parse line items from JSON + try { + const lineItems = typeof order.lineItems === "string" + ? JSON.parse(order.lineItems) + : order.lineItems + + if (!Array.isArray(lineItems)) return false + + // Check if any line item's product_id matches a synced product + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return lineItems.some((item: any) => { + const productId = item.product_id?.toString() + return productId && syncedProductIds.has(productId) + }) + } catch { + return false + } + }) + + // Transform orders to include supplier-relevant information + const transformedOrders = relevantOrders.map((order) => { + const syncedProductIds = syncsByStore.get(order.storeId) || new Set() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let lineItems: any[] = [] + + try { + lineItems = typeof order.lineItems === "string" + ? JSON.parse(order.lineItems) + : order.lineItems + } catch { + lineItems = [] + } + + // Filter line items to only show synced products + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncedLineItems = lineItems.filter((item: any) => { + const productId = item.product_id?.toString() + return productId && syncedProductIds.has(productId) + }) + + return { + id: order.shopifyOrderId, + orderNumber: order.orderNumber, + email: order.email, + totalPrice: order.totalPrice, + currency: order.currency, + financialStatus: order.financialStatus, + fulfillmentStatus: order.fulfillmentStatus, + createdAt: order.shopifyCreatedAt || order.createdAt, + store: order.store, + lineItems: syncedLineItems, + // Include all line items for reference, but mark synced ones + // eslint-disable-next-line @typescript-eslint/no-explicit-any + allLineItems: lineItems.map((item: any) => ({ + ...item, + isSynced: syncedProductIds.has(item.product_id?.toString() || "") + })) + } + }) + + return NextResponse.json({ + orders: transformedOrders, + connectedVendors: connections.length, + syncedProducts: acceptedSyncs.length, + totalOrders: transformedOrders.length + }) + } catch (error) { + console.error("Error fetching orders:", error) + return NextResponse.json( + { message: "Failed to fetch orders" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/product-syncs/route.ts b/src/app/api/supplier/product-syncs/route.ts new file mode 100644 index 0000000..09b6e49 --- /dev/null +++ b/src/app/api/supplier/product-syncs/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/supplier/product-syncs + * Get all product sync requests for the authenticated supplier + */ +export async function GET(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(req.url) + const status = searchParams.get("status") // "PENDING", "ACCEPTED", "REJECTED", or null for all + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ syncs: [] }) + } + + // Build where clause + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const where: any = { + supplierId: supplierProfile.id + } + + if (status && ["PENDING", "ACCEPTED", "REJECTED"].includes(status)) { + where.status = status + } + + // Get product syncs + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncs = await (prisma as any).productSync.findMany({ + where, + include: { + store: { + select: { + id: true, + businessName: true, + shopifyDomain: true, + user: { + select: { + name: true, + email: true + } + } + } + } + }, + orderBy: { + createdAt: "desc" + } + }) + + return NextResponse.json({ syncs }) + } catch (error) { + console.error("Error fetching product syncs:", error) + return NextResponse.json( + { message: "Failed to fetch product syncs" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/products/[productId]/route.ts b/src/app/api/supplier/products/[productId]/route.ts new file mode 100644 index 0000000..143a221 --- /dev/null +++ b/src/app/api/supplier/products/[productId]/route.ts @@ -0,0 +1,184 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * PUT /api/supplier/products/[productId] + * Update a product + */ +export async function PUT( + req: Request, + { params }: { params: Promise<{ productId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { productId } = await params + const { title, description, price, sku, imageUrl } = await req.json() + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Check if this is a synced product (starts with "synced-") + if (productId.startsWith("synced-")) { + const syncId = productId.replace("synced-", "") + + // Update synced product data in ProductSync + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const productSync = await (prisma as any).productSync.findUnique({ + where: { id: syncId }, + include: { + supplier: { + select: { id: true } + } + } + }) + + if (!productSync || productSync.supplierId !== supplierProfile.id) { + return NextResponse.json({ message: "Synced product not found" }, { status: 404 }) + } + + // Update the shopifyProductData with new values + const updatedData = { + ...(productSync.shopifyProductData || {}), + title: title || productSync.shopifyProductTitle, + body_html: description !== undefined ? description : (productSync.shopifyProductData?.body_html || ""), + variants: productSync.shopifyProductData?.variants?.map((v: any, idx: number) => ({ + ...v, + price: idx === 0 && price !== undefined ? price.toString() : v.price, + sku: idx === 0 && sku !== undefined ? sku : v.sku + })) || [], + image: imageUrl ? { src: imageUrl } : productSync.shopifyProductData?.image + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (prisma as any).productSync.update({ + where: { id: syncId }, + data: { + shopifyProductTitle: title || productSync.shopifyProductTitle, + shopifyProductData: updatedData + } + }) + + return NextResponse.json({ + message: "Synced product updated successfully", + product: { + id: productId, + syncId, + title: title || productSync.shopifyProductTitle, + description, + price: parseFloat(price || "0"), + sku, + imageUrl, + isSynced: true + } + }) + } + + // Regular product update + const existingProduct = await prisma.product.findFirst({ + where: { + id: productId, + supplierId: supplierProfile.id + } + }) + + if (!existingProduct) { + return NextResponse.json({ message: "Product not found" }, { status: 404 }) + } + + const product = await prisma.product.update({ + where: { id: productId }, + data: { + title: title || existingProduct.title, + description: description !== undefined ? description : existingProduct.description, + price: price !== undefined ? parseFloat(price) : existingProduct.price, + sku: sku !== undefined ? sku : existingProduct.sku, + imageUrl: imageUrl !== undefined ? imageUrl : existingProduct.imageUrl + } + }) + + return NextResponse.json({ + message: "Product updated successfully", + product + }) + } catch (error) { + console.error("Error updating product:", error) + return NextResponse.json( + { message: "Failed to update product" }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/supplier/products/[productId] + * Delete a product + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ productId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { productId } = await params + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Check if this is a synced product - synced products cannot be deleted + if (productId.startsWith("synced-")) { + return NextResponse.json( + { message: "Synced products from vendors cannot be deleted. You can only edit them." }, + { status: 400 } + ) + } + + // Verify product belongs to supplier + const existingProduct = await prisma.product.findFirst({ + where: { + id: productId, + supplierId: supplierProfile.id + } + }) + + if (!existingProduct) { + return NextResponse.json({ message: "Product not found" }, { status: 404 }) + } + + await prisma.product.delete({ + where: { id: productId } + }) + + return NextResponse.json({ + message: "Product deleted successfully" + }) + } catch (error) { + console.error("Error deleting product:", error) + return NextResponse.json( + { message: "Failed to delete product" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/products/route.ts b/src/app/api/supplier/products/route.ts new file mode 100644 index 0000000..f08b0fb --- /dev/null +++ b/src/app/api/supplier/products/route.ts @@ -0,0 +1,145 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/supplier/products + * List all products for the authenticated supplier + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Get supplier's own products + const ownProducts = await prisma.product.findMany({ + where: { + supplierId: supplierProfile.id + }, + orderBy: { + createdAt: "desc" + } + }) + + // Get synced products from vendors (products that vendors synced with this supplier) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const syncedProducts = await (prisma as any).productSync.findMany({ + where: { + supplierId: supplierProfile.id, + status: "ACCEPTED" + }, + include: { + store: { + select: { + id: true, + businessName: true + } + } + }, + orderBy: { + acceptedAt: "desc" + } + }) + + // Transform synced products to match product format + const syncedProductsFormatted = syncedProducts.map((sync: any) => { + const productData = sync.shopifyProductData || {} + return { + id: `synced-${sync.id}`, // Unique ID for synced products + syncId: sync.id, + shopifyProductId: sync.shopifyProductId, + title: sync.shopifyProductTitle, + description: productData.body_html || productData.description || null, + price: parseFloat(productData.variants?.[0]?.price || "0"), + sku: productData.variants?.[0]?.sku || null, + imageUrl: productData.image?.src || productData.images?.[0]?.src || null, + createdAt: sync.createdAt, + updatedAt: sync.updatedAt || sync.acceptedAt, + vendor: { + id: sync.store.id, + businessName: sync.store.businessName + }, + isSynced: true, // Flag to indicate this is a synced product from vendor + // Include full Shopify product data for reference + shopifyData: productData + } + }) + + // Combine own products and synced products + const allProducts = [...ownProducts, ...syncedProductsFormatted] + + return NextResponse.json({ products: allProducts }) + } catch (error) { + console.error("Error fetching products:", error) + return NextResponse.json( + { message: "Failed to fetch products" }, + { status: 500 } + ) + } +} + +/** + * POST /api/supplier/products + * Create a new product + */ +export async function POST(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { title, description, price, sku, imageUrl } = await req.json() + + if (!title || price === undefined) { + return NextResponse.json( + { message: "Title and price are required" }, + { status: 400 } + ) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + const product = await prisma.product.create({ + data: { + supplierId: supplierProfile.id, + title, + description: description || null, + price: parseFloat(price), + sku: sku || null, + imageUrl: imageUrl || null + } + }) + + return NextResponse.json({ + message: "Product created successfully", + product + }) + } catch (error) { + console.error("Error creating product:", error) + return NextResponse.json( + { message: "Failed to create product" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/profile/route.ts b/src/app/api/supplier/profile/route.ts new file mode 100644 index 0000000..8fcda0e --- /dev/null +++ b/src/app/api/supplier/profile/route.ts @@ -0,0 +1,100 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/supplier/profile + * Get supplier profile for authenticated user + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id }, + include: { + user: { + select: { + name: true, + email: true + } + }, + _count: { + select: { + products: true, + connections: true + } + } + } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + return NextResponse.json({ profile: supplierProfile }) + } catch (error) { + console.error("Error fetching supplier profile:", error) + return NextResponse.json( + { message: "Failed to fetch supplier profile" }, + { status: 500 } + ) + } +} + +/** + * POST /api/supplier/profile + * Create or update supplier profile + */ +export async function POST(req: Request) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { companyName, description } = await req.json() + + if (!companyName) { + return NextResponse.json({ message: "Company name is required" }, { status: 400 }) + } + + // Upsert supplier profile + const supplierProfile = await prisma.supplierProfile.upsert({ + where: { userId: session.user.id }, + update: { + companyName, + description: description || null + }, + create: { + userId: session.user.id, + companyName, + description: description || null + }, + include: { + user: { + select: { + name: true, + email: true + } + } + } + }) + + return NextResponse.json({ + message: "Supplier profile updated successfully", + profile: supplierProfile + }) + } catch (error) { + console.error("Error updating supplier profile:", error) + return NextResponse.json( + { message: "Failed to update supplier profile" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/vendors/[connectionId]/route.ts b/src/app/api/supplier/vendors/[connectionId]/route.ts new file mode 100644 index 0000000..166e2a8 --- /dev/null +++ b/src/app/api/supplier/vendors/[connectionId]/route.ts @@ -0,0 +1,121 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * PUT /api/supplier/vendors/[connectionId] + * Update connection status (accept/reject) + */ +export async function PUT( + req: Request, + { params }: { params: Promise<{ connectionId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { connectionId } = await params + const { status } = await req.json() + + if (!status || !["CONNECTED", "REJECTED"].includes(status)) { + return NextResponse.json( + { message: "Valid status (CONNECTED or REJECTED) is required" }, + { status: 400 } + ) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Verify connection belongs to supplier + const connection = await prisma.connection.findFirst({ + where: { + id: connectionId, + supplierId: supplierProfile.id + } + }) + + if (!connection) { + return NextResponse.json({ message: "Connection not found" }, { status: 404 }) + } + + const updatedConnection = await prisma.connection.update({ + where: { id: connectionId }, + data: { status: status as "CONNECTED" | "REJECTED" } + }) + + return NextResponse.json({ + message: `Connection ${status.toLowerCase()} successfully`, + connection: updatedConnection + }) + } catch (error) { + console.error("Error updating connection:", error) + return NextResponse.json( + { message: "Failed to update connection" }, + { status: 500 } + ) + } +} + +/** + * DELETE /api/supplier/vendors/[connectionId] + * Delete a connection + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ connectionId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { connectionId } = await params + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + // Verify connection belongs to supplier + const connection = await prisma.connection.findFirst({ + where: { + id: connectionId, + supplierId: supplierProfile.id + } + }) + + if (!connection) { + return NextResponse.json({ message: "Connection not found" }, { status: 404 }) + } + + await prisma.connection.delete({ + where: { id: connectionId } + }) + + return NextResponse.json({ + message: "Connection deleted successfully" + }) + } catch (error) { + console.error("Error deleting connection:", error) + return NextResponse.json( + { message: "Failed to delete connection" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/supplier/vendors/route.ts b/src/app/api/supplier/vendors/route.ts new file mode 100644 index 0000000..3c6c520 --- /dev/null +++ b/src/app/api/supplier/vendors/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/supplier/vendors + * List all vendor connections for the authenticated supplier + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "SUPPLIER") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get supplier profile + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier profile not found" }, { status: 404 }) + } + + const connections = await prisma.connection.findMany({ + where: { + supplierId: supplierProfile.id + }, + include: { + store: { + select: { + id: true, + businessName: true, + shopifyDomain: true, + shopifyStoreName: true, + user: { + select: { + name: true, + email: true + } + } + } + } + }, + orderBy: { + createdAt: "desc" + } + }) + + return NextResponse.json({ connections }) + } catch (error) { + console.error("Error fetching vendor connections:", error) + return NextResponse.json( + { message: "Failed to fetch vendor connections" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts b/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts new file mode 100644 index 0000000..2b21221 --- /dev/null +++ b/src/app/api/vendors/suppliers/[supplierId]/invite/route.ts @@ -0,0 +1,317 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { sendEmail, generateInvitationEmailHtml } from "@/lib/email" +import { notifyInvitationReceived } from "@/lib/notifications" +import crypto from "crypto" + +/** + * DELETE /api/vendors/suppliers/[supplierId]/invite + * Cancel a pending invitation + */ +export async function DELETE( + req: Request, + { params }: { params: Promise<{ supplierId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { supplierId } = await params + const body = await req.json().catch(() => ({})) + const storeId = body.storeId // Optional: specific store to cancel, otherwise cancel all + + // Get vendor's stores + const vendorStores = await prisma.store.findMany({ + where: { + userId: session.user.id, + onboardingComplete: true + } + }) + + if (vendorStores.length === 0) { + return NextResponse.json( + { message: "No stores found" }, + { status: 400 } + ) + } + + const storeIdsToCheck = storeId + ? [storeId] + : vendorStores.map(s => s.id) + + // Find and delete pending invitations + const pendingInvitations = await (prisma as any).invitation.findMany({ + where: { + storeId: { in: storeIdsToCheck }, + supplierId: supplierId, + used: false, + expiresAt: { + gt: new Date() + } + } + }) + + if (pendingInvitations.length === 0) { + return NextResponse.json( + { message: "No pending invitations found" }, + { status: 404 } + ) + } + + // Delete all pending invitations + await (prisma as any).invitation.deleteMany({ + where: { + id: { in: pendingInvitations.map((inv: any) => inv.id) } + } + }) + + return NextResponse.json({ + message: `Successfully cancelled ${pendingInvitations.length} invitation(s)` + }) + } catch (error) { + console.error("Error cancelling invitation:", error) + return NextResponse.json( + { message: "Failed to cancel invitation" }, + { status: 500 } + ) + } +} + +/** + * POST /api/vendors/suppliers/[supplierId]/invite + * Send invitation to a supplier + */ +export async function POST( + req: Request, + { params }: { params: Promise<{ supplierId: string }> } +) { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + const { supplierId } = await params + const body = await req.json().catch(() => ({})) + const connectToAllStores = body.connectToAllStores === true + + // Get vendor's stores + const vendorStores = await prisma.store.findMany({ + where: { + userId: session.user.id, + onboardingComplete: true // Only include completed stores + }, + select: { + id: true, + businessName: true, + userId: true, + isActive: true + } + }) + + if (vendorStores.length === 0) { + return NextResponse.json( + { message: "No stores found. Please complete onboarding first." }, + { status: 400 } + ) + } + + // Determine which stores to connect + const storesToConnect = connectToAllStores + ? vendorStores + : vendorStores.filter(store => store.isActive) + + if (storesToConnect.length === 0) { + return NextResponse.json( + { message: "No active store found. Please activate a store or complete onboarding." }, + { status: 400 } + ) + } + + // Get supplier profile with user info + const supplierProfile = await prisma.supplierProfile.findUnique({ + where: { id: supplierId }, + include: { + user: { + select: { + id: true, + email: true, + name: true + } + } + } + }) + + // Get vendor stores with user info for notifications + const vendorStoresWithUser = await prisma.store.findMany({ + where: { + userId: session.user.id, + onboardingComplete: true + }, + select: { + id: true, + businessName: true, + userId: true + } + }) + + if (!supplierProfile) { + return NextResponse.json({ message: "Supplier not found" }, { status: 404 }) + } + + // Validate email exists + if (!supplierProfile.user.email) { + return NextResponse.json( + { message: "Supplier email not found" }, + { status: 400 } + ) + } + + // Check for existing connections and invitations for all stores + const storeIds = storesToConnect.map(s => s.id) + const existingConnections = await prisma.connection.findMany({ + where: { + storeId: { in: storeIds }, + supplierId: supplierProfile.id + } + }) + + const existingInvitations = await (prisma as any).invitation.findMany({ + where: { + storeId: { in: storeIds }, + supplierId: supplierProfile.id, + used: false, + expiresAt: { + gt: new Date() + } + } + }) + + // Filter out stores that already have connections or pending invitations + const connectedStoreIds = new Set([ + ...existingConnections.map(c => c.storeId), + ...existingInvitations.map((i: any) => i.storeId) + ]) + + const storesToInvite = storesToConnect.filter(store => !connectedStoreIds.has(store.id)) + + if (storesToInvite.length === 0) { + const allConnected = storesToConnect.length === existingConnections.length + existingInvitations.length + return NextResponse.json( + { + message: allConnected + ? "All selected stores are already connected or have pending invitations" + : "No stores available to invite (all have existing connections or pending invitations)" + }, + { status: 400 } + ) + } + + // Generate invitation tokens and create invitations for each store + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) // 7 days expiry + + const invitations = [] + const storeNames = storesToInvite.map(s => s.businessName).join(", ") + + for (const store of storesToInvite) { + const invitationToken = crypto.randomBytes(32).toString("hex").toUpperCase().slice(0, 16) + + try { + const invitation = await (prisma as any).invitation.create({ + data: { + token: invitationToken, + storeId: store.id, + supplierId: supplierProfile.id, + email: supplierProfile.user.email, + expiresAt + } + }) + invitations.push({ id: invitation.id, storeId: store.id, token: invitationToken }) + } catch (dbError: any) { + console.error(`Database error creating invitation for store ${store.id}:`, dbError) + // Continue with other stores + } + } + + if (invitations.length === 0) { + return NextResponse.json( + { message: "Failed to create invitations" }, + { status: 500 } + ) + } + + // Send invitation email (use first store's name or combined names) + const primaryStore = storesToInvite[0] + const invitationLink = `${process.env.NEXTAUTH_URL || "http://localhost:3000"}/supplier/vendors?token=${invitations[0].token}` + + const emailSubject = connectToAllStores && storesToInvite.length > 1 + ? `Invitation to Connect - ${storesToInvite.length} Stores` + : `Invitation to Connect - ${primaryStore.businessName}` + + const emailResult = await sendEmail({ + to: supplierProfile.user.email, + subject: emailSubject, + html: generateInvitationEmailHtml( + supplierProfile.user.name || supplierProfile.companyName, + connectToAllStores && storesToInvite.length > 1 ? storeNames : primaryStore.businessName, + invitations[0].token, // Use first token in email + invitationLink + ) + }) + + if (!emailResult.success) { + console.warn("Email sending failed (but invitations created):", emailResult.error) + // Continue anyway - invitations are created, supplier can use tokens manually + } + + // Create notification for supplier about the invitation + try { + const vendorName = session.user.name || storesToInvite[0].businessName + const storeDisplayName = connectToAllStores && storesToInvite.length > 1 + ? `${storesToInvite.length} Stores` + : primaryStore.businessName + + await notifyInvitationReceived( + supplierProfile.user.id, + vendorName, + storeDisplayName, + invitations[0].id // Use first invitation ID + ) + } catch (notifError) { + console.error("Error creating notification:", notifError) + // Don't fail the request if notification fails + } + + return NextResponse.json({ + message: connectToAllStores && storesToInvite.length > 1 + ? `Invitations sent successfully to ${invitations.length} store(s)` + : "Invitation sent successfully", + invitations: invitations.map(inv => ({ + id: inv.id, + storeId: inv.storeId, + token: inv.token, + expiresAt + })), + storesConnected: invitations.length, + totalStores: storesToConnect.length + }) + } catch (error) { + console.error("Error sending invitation:", error) + const errorMessage = error instanceof Error ? error.message : "Unknown error" + const errorStack = error instanceof Error ? error.stack : undefined + console.error("Error details:", { errorMessage, errorStack }) + return NextResponse.json( + { + message: "Failed to send invitation", + error: errorMessage + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/vendors/suppliers/my/route.ts b/src/app/api/vendors/suppliers/my/route.ts new file mode 100644 index 0000000..2eb76cc --- /dev/null +++ b/src/app/api/vendors/suppliers/my/route.ts @@ -0,0 +1,133 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/vendors/suppliers/my + * List connected suppliers for the authenticated vendor + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get vendor's stores + const vendorStores = await prisma.store.findMany({ + where: { + userId: session.user.id, + onboardingComplete: true + } + }) + + if (vendorStores.length === 0) { + return NextResponse.json({ connections: [] }) + } + + // Get all connections for this vendor's stores + const vendorStoreIds = vendorStores.map(s => s.id) + const connections = await prisma.connection.findMany({ + where: { + storeId: { in: vendorStoreIds } + }, + include: { + store: { + select: { + id: true, + businessName: true, + isActive: true + } + }, + supplier: { + select: { + id: true, + companyName: true, + description: true, + user: { + select: { + name: true, + email: true + } + }, + _count: { + select: { + products: true + } + } + } + } + }, + orderBy: { + createdAt: "desc" + } + }) + + // Get pending invitations (not yet accepted - no connection created) + const pendingInvitations = await (prisma as any).invitation.findMany({ + where: { + storeId: { in: vendorStoreIds }, + used: false, + expiresAt: { + gt: new Date() + } + }, + include: { + supplier: { + select: { + id: true, + companyName: true, + description: true, + user: { + select: { + name: true, + email: true + } + }, + _count: { + select: { + products: true + } + } + } + } + }, + orderBy: { + createdAt: "desc" + } + }) + + // Convert invitations to connection-like objects with PENDING status + const pendingConnections = pendingInvitations.map((invitation: any) => ({ + id: invitation.id, // Use invitation ID as temporary connection ID + status: "PENDING" as const, + createdAt: invitation.createdAt, + supplier: invitation.supplier, + store: { + id: invitation.storeId, + businessName: vendorStores.find(s => s.id === invitation.storeId)?.businessName || "Unknown Store", + isActive: vendorStores.find(s => s.id === invitation.storeId)?.isActive || false + } + })) + + // Combine connections and pending invitations, removing duplicates + // (if a connection exists, don't include the invitation) + const connectionSupplierIds = new Set(connections.map(c => c.supplier.id)) + const uniquePendingConnections = pendingConnections.filter( + (pc: any) => !connectionSupplierIds.has(pc.supplier.id) + ) + + const allConnections = [...connections, ...uniquePendingConnections].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ) + + return NextResponse.json({ connections: allConnections }) + } catch (error) { + console.error("Error fetching my suppliers:", error) + return NextResponse.json( + { message: "Failed to fetch suppliers" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/vendors/suppliers/route.ts b/src/app/api/vendors/suppliers/route.ts new file mode 100644 index 0000000..6cf856e --- /dev/null +++ b/src/app/api/vendors/suppliers/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/vendors/suppliers + * List all suppliers globally (for vendors to discover) + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get all supplier profiles with their user info and product counts + const suppliers = await prisma.supplierProfile.findMany({ + select: { + id: true, + companyName: true, + description: true, + createdAt: true, + user: { + select: { + id: true, + name: true, + email: true + } + }, + _count: { + select: { + products: true, + connections: true + } + } + }, + orderBy: { + createdAt: "desc" + } + }) + + // Get vendor's store to check existing connections + const vendorStore = await prisma.store.findFirst({ + where: { + userId: session.user.id, + isActive: true + } + }) + + // Mark which suppliers are already connected/pending + const suppliersWithStatus = await Promise.all( + suppliers.map(async (supplier) => { + let connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" = "NONE" + + if (vendorStore) { + // First check for existing connection + const connection = await prisma.connection.findUnique({ + where: { + storeId_supplierId: { + storeId: vendorStore.id, + supplierId: supplier.id + } + }, + select: { + status: true + } + }) + + if (connection) { + connectionStatus = connection.status as "PENDING" | "CONNECTED" | "REJECTED" + } else { + // Check for pending invitation (not yet accepted) + const pendingInvitation = await (prisma as any).invitation.findFirst({ + where: { + storeId: vendorStore.id, + supplierId: supplier.id, + used: false, + expiresAt: { + gt: new Date() + } + } + }) + + if (pendingInvitation) { + connectionStatus = "PENDING" + } + } + } + + return { + ...supplier, + connectionStatus + } + }) + ) + + return NextResponse.json({ suppliers: suppliersWithStatus }) + } catch (error) { + console.error("Error fetching suppliers:", error) + return NextResponse.json( + { message: "Failed to fetch suppliers" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/vendors/synced-products/route.ts b/src/app/api/vendors/synced-products/route.ts new file mode 100644 index 0000000..4957faf --- /dev/null +++ b/src/app/api/vendors/synced-products/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server" +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" + +/** + * GET /api/vendors/synced-products + * Get all synced products for the vendor (read-only) + * These are products that vendors have synced with suppliers + */ +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id || session.user.role !== "VENDOR") { + return NextResponse.json({ message: "Unauthorized" }, { status: 401 }) + } + + // Get vendor's active store + const vendorStore = await prisma.store.findFirst({ + where: { + userId: session.user.id, + isActive: true + } + }) + + if (!vendorStore) { + return NextResponse.json({ products: [] }) + } + + // Get all ACCEPTED product syncs for this store + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const acceptedSyncs = await (prisma as any).productSync.findMany({ + where: { + storeId: vendorStore.id, + status: "ACCEPTED" + }, + include: { + supplier: { + select: { + id: true, + companyName: true + } + } + }, + orderBy: { + acceptedAt: "desc" + } + }) + + // Transform syncs into product-like objects for display + const syncedProducts = acceptedSyncs.map((sync: any) => { + const productData = sync.shopifyProductData || {} + return { + id: sync.id, + syncId: sync.id, + shopifyProductId: sync.shopifyProductId, + title: sync.shopifyProductTitle, + description: productData.body_html || productData.description || null, + price: productData.variants?.[0]?.price || "0", + sku: productData.variants?.[0]?.sku || null, + imageUrl: productData.image?.src || productData.images?.[0]?.src || null, + supplier: { + id: sync.supplier.id, + companyName: sync.supplier.companyName + }, + syncedAt: sync.acceptedAt || sync.createdAt, + isSynced: true, // Flag to indicate this is a synced product + // Include full Shopify product data for reference + shopifyData: productData + } + }) + + return NextResponse.json({ products: syncedProducts }) + } catch (error) { + console.error("Error fetching synced products:", error) + return NextResponse.json( + { message: "Failed to fetch synced products" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/webhooks/shopify/route.ts b/src/app/api/webhooks/shopify/route.ts new file mode 100644 index 0000000..df503e2 --- /dev/null +++ b/src/app/api/webhooks/shopify/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { updateSingleProduct, updateSingleOrder } from "@/lib/cache" +import crypto from "crypto" +import { logger } from "@/lib/logger" + +// Ensure raw body is available for HMAC verification +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** + * Verify Shopify webhook HMAC signature + */ +function verifyWebhook(hmac: string, body: string, secret: string): boolean { + const hash = crypto + .createHmac("sha256", secret) + .update(body, "utf8") + .digest("base64") + return hash === hmac +} + +export async function POST(req: NextRequest) { + try { + const hmac = req.headers.get("x-shopify-hmac-sha256") + const topic = req.headers.get("x-shopify-topic") + const domain = req.headers.get("x-shopify-shop-domain") + + if (!hmac || !topic || !domain) { + logger.error("[Webhook] Missing required headers") + return new NextResponse("Missing Shopify headers", { status: 400 }) + } + + // Get raw body for HMAC verification + const rawBody = await req.text() + const body = JSON.parse(rawBody) + + logger.info(`[Shopify Webhook] Received ${topic} from ${domain}`) + + // Clean domain to match database format + const cleanDomain = domain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "").trim() + + // Find the store by domain + let store = await prisma.store.findFirst({ + where: { shopifyDomain: cleanDomain } + }) + + if (!store) { + store = await prisma.store.findFirst({ + where: { shopifyDomain: domain } + }) + } + + if (!store) { + logger.error(`[Webhook] Store not found for domain: ${domain}`) + return new NextResponse("Store not found", { status: 404 }) + } + + // Verify HMAC signature + if (!verifyWebhook(hmac, rawBody, store.shopifyApiSecret)) { + logger.error(`[Webhook] HMAC verification failed for ${domain}`) + return new NextResponse("Invalid signature", { status: 401 }) + } + + // Handle specific topics and update cache + sync metadata + switch (topic) { + case "orders/create": + case "orders/updated": + logger.info(`[Webhook] Processing order ${topic}: ${body.id}`) + await updateSingleOrder(store.id, body) + // Update sync metadata to invalidate cache + await prisma.syncMetadata.upsert({ + where: { + storeId_resourceType: { + storeId: store.id, + resourceType: "orders" + } + }, + create: { + storeId: store.id, + resourceType: "orders", + lastSyncAt: new Date(), + syncStatus: "success", + recordCount: 1 + }, + update: { + lastSyncAt: new Date(), + syncStatus: "success" + } + }) + break + + case "products/update": + case "products/create": + logger.info(`[Webhook] Processing product ${topic}: ${body.id}`) + await updateSingleProduct(store.id, body) + // Update sync metadata to invalidate cache + await prisma.syncMetadata.upsert({ + where: { + storeId_resourceType: { + storeId: store.id, + resourceType: "products" + } + }, + create: { + storeId: store.id, + resourceType: "products", + lastSyncAt: new Date(), + syncStatus: "success", + recordCount: 1 + }, + update: { + lastSyncAt: new Date(), + syncStatus: "success" + } + }) + break + + case "products/delete": + logger.info(`[Webhook] Processing product deletion: ${body.id}`) + await prisma.cachedProduct.deleteMany({ + where: { + shopifyProductId: body.id.toString(), + storeId: store.id + } + }) + // Update sync metadata + await prisma.syncMetadata.upsert({ + where: { + storeId_resourceType: { + storeId: store.id, + resourceType: "products" + } + }, + create: { + storeId: store.id, + resourceType: "products", + lastSyncAt: new Date(), + syncStatus: "success" + }, + update: { + lastSyncAt: new Date() + } + }) + break + + default: + logger.warn(`[Webhook] Unhandled topic: ${topic}`) + } + + logger.info(`[Webhook] Successfully processed ${topic} for ${domain}`) + return new NextResponse("OK", { status: 200 }) + + } catch (error) { + logger.error("[Webhook] Processing error:", error) + return new NextResponse("Internal Server Error", { status: 500 }) + } +} diff --git a/src/app/dashboard/customers/page.tsx b/src/app/dashboard/customers/page.tsx new file mode 100644 index 0000000..b1ceb92 --- /dev/null +++ b/src/app/dashboard/customers/page.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Users, + Search, + MoreHorizontal, + Mail, + Plus, + Loader2, + AlertCircle, + MapPin +} from "lucide-react" + +interface ShopifyCustomer { + id: number + email: string + first_name: string + last_name: string + orders_count: number + state: string + total_spent: string + last_order_id: number | null + created_at: string + default_address: { + city: string + country_name: string + } | null +} + +export default function CustomersPage() { + const [customers, setCustomers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [searchTerm, setSearchTerm] = useState("") + + useEffect(() => { + const fetchCustomers = async () => { + try { + const res = await fetch("/api/shopify/customers") + if (!res.ok) throw new Error("Failed to fetch customers") + const data = await res.json() + setCustomers(data.customers || []) + setError("") + } catch { + setError("Could not load customers. Please ensure your Shopify store is connected.") + } finally { + setLoading(false) + } + } + + // Initial fetch + fetchCustomers() + + // Auto-refresh every 20 seconds to catch webhook updates + const interval = setInterval(() => { + fetchCustomers() + }, 20000) // 20 seconds + + return () => clearInterval(interval) + }, []) + + const filteredCustomers = customers.filter(c => + `${c.first_name} ${c.last_name}`.toLowerCase().includes(searchTerm.toLowerCase()) || + c.email.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + return ( +
+
+
+

Customers

+

View and manage your Shopify customer base.

+
+
+ +
+
+ +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+
+ +
+
+ {loading ? ( +
+ +

Loading customers...

+
+ ) : error ? ( +
+
+ +
+

{error}

+
+ ) : filteredCustomers.length === 0 ? ( +
+ +

No customers found.

+
+ ) : ( + + + + + + + + + + + + + {filteredCustomers.map((customer) => ( + + + + + + + + + ))} + +
CustomerOrdersSpentLocationMember SinceActions
+
+
+ {customer.first_name?.[0]}{customer.last_name?.[0]} +
+
+ {customer.first_name} {customer.last_name} +
+ + {customer.email} +
+
+ + {customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"} +
+
+
+
+
+ {customer.orders_count} ORDERS +
+
+ ${customer.total_spent} + +
+ + {customer.default_address ? `${customer.default_address.city}, ${customer.default_address.country_name}` : "Not provided"} +
+
+ {new Date(customer.created_at).toLocaleDateString()} + + +
+ )} +
+
+
+
+ ) +} diff --git a/src/app/dashboard/help-support/page.tsx b/src/app/dashboard/help-support/page.tsx new file mode 100644 index 0000000..84270c9 --- /dev/null +++ b/src/app/dashboard/help-support/page.tsx @@ -0,0 +1,225 @@ +"use client" + +import { useState } from "react" +import { + HelpCircle, + Book, + MessageCircle, + Video, + FileText, + Mail, + Search, + ChevronRight, + Package, + ShoppingCart, + Users +} from "lucide-react" + +interface FAQItem { + id: string + question: string + answer: string + category: "getting-started" | "inventory" | "orders" | "suppliers" | "general" +} + +const faqCategories = [ + { id: "getting-started", label: "Getting Started", icon: Book }, + { id: "inventory", label: "Inventory Management", icon: Package }, + { id: "orders", label: "Orders & Fulfillment", icon: ShoppingCart }, + { id: "suppliers", label: "Suppliers", icon: Users }, + { id: "general", label: "General", icon: HelpCircle }, +] + +const faqs: FAQItem[] = [ + { + id: "1", + question: "How do I connect my Shopify store?", + answer: "Navigate to Stores > Add Store and enter your Shopify store credentials. You'll need your store domain, API key, and API secret. Follow the step-by-step onboarding process to complete the connection.", + category: "getting-started" + }, + { + id: "2", + question: "How do I sync my products?", + answer: "Products are automatically synced from your connected Shopify store. You can manually refresh by going to Inventory and clicking the refresh button. Products sync every 10 seconds automatically.", + category: "inventory" + }, + { + id: "3", + question: "How do I manage orders?", + answer: "All orders from your Shopify store appear in the Orders page. You can view order details, track fulfillment status, and manage customer information. Orders sync automatically every 15 seconds.", + category: "orders" + }, + { + id: "4", + question: "How do I connect with suppliers?", + answer: "Go to Suppliers > Discover Global to browse available suppliers. You can connect with suppliers, view their products, and manage your supplier relationships from the My Suppliers page.", + category: "suppliers" + }, + { + id: "5", + question: "What is JUNO Engine?", + answer: "JUNO Engine is our communication and support infrastructure. It includes ticket management, email flagging, Gmail integration, and email templates to help you manage customer communications efficiently.", + category: "general" + }, + { + id: "6", + question: "How do I export my data?", + answer: "You can export reports from various pages using the Export button. Currently, you can export order data as CSV. More export options are coming soon.", + category: "general" + } +] + +export default function HelpSupportPage() { + const [searchTerm, setSearchTerm] = useState("") + const [selectedCategory, setSelectedCategory] = useState(null) + const [expandedFaq, setExpandedFaq] = useState(null) + + const filteredFaqs = faqs.filter(faq => { + const matchesSearch = faq.question.toLowerCase().includes(searchTerm.toLowerCase()) || + faq.answer.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesCategory = !selectedCategory || faq.category === selectedCategory + return matchesSearch && matchesCategory + }) + + return ( +
+
+
+

Help & Support

+

Find answers and get help with JUNOHUB.

+
+
+ + {/* Quick Actions */} + + + {/* Search */} +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full h-12 pl-12 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+
+
+ + {/* Categories */} +
+ + {faqCategories.map((category) => { + const Icon = category.icon + return ( + + ) + })} +
+ + {/* FAQ Section */} +
+
+ {filteredFaqs.length === 0 ? ( +
+ +

No results found

+

Try a different search term or category

+
+ ) : ( + filteredFaqs.map((faq) => ( +
+ +
+ )) + )} +
+
+
+ ) +} diff --git a/src/app/dashboard/inventory/page.tsx b/src/app/dashboard/inventory/page.tsx new file mode 100644 index 0000000..8715202 --- /dev/null +++ b/src/app/dashboard/inventory/page.tsx @@ -0,0 +1,641 @@ +"use client" + +import { useEffect, useState, useRef } from "react" +import Image from "next/image" +import toast from "react-hot-toast" +import { + Package, + Search, + Filter, + MoreHorizontal, + Download, + Loader2, + AlertCircle, + Image as ImageIcon, + Eye, + ExternalLink, + Copy, + RefreshCw, + X, + CheckCircle2, + ChevronLeft, + ChevronRight +} from "lucide-react" + +interface ShopifyProduct { + id: number + title: string + body_html: string + vendor: string + product_type: string + created_at: string + handle: string + updated_at: string + published_at: string + template_suffix: string + status: string + published_scope: string + tags: string + admin_graphql_api_id: string + variants: unknown[] + options: unknown[] + images: { src: string }[] + image: { src: string } | null +} + +interface ProductSync { + id: string + shopifyProductId: string + shopifyProductTitle: string + status: "PENDING" | "ACCEPTED" | "REJECTED" + supplier: { + id: string + companyName: string + } +} + +interface Supplier { + id: string + companyName: string + description: string | null + user: { + email: string + } + connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" +} + +export default function InventoryPage() { + const [products, setProducts] = useState([]) + const [productSyncs, setProductSyncs] = useState>(new Map()) + const [connectedSuppliers, setConnectedSuppliers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [searchTerm, setSearchTerm] = useState("") + const [openMenuId, setOpenMenuId] = useState(null) + const [storeDomain, setStoreDomain] = useState(null) + const [selectedProduct, setSelectedProduct] = useState(null) + const [showSupplierModal, setShowSupplierModal] = useState(false) + const [syncingProduct, setSyncingProduct] = useState(false) + const [statusFilter, setStatusFilter] = useState("all") + const [vendorFilter, setVendorFilter] = useState("all") + const [showFilters, setShowFilters] = useState(false) + const menuRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}) + + useEffect(() => { + const fetchStoreInfo = async () => { + try { + const res = await fetch("/api/stores") + if (res.ok) { + const data = await res.json() + const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) + if (activeStore?.shopifyDomain) { + const cleanDomain = activeStore.shopifyDomain.replace(/^https?:\/\//, "").replace(/\/$/, "") + setStoreDomain(cleanDomain) + } + } + } catch { + // Silent fail - store domain not critical + } + } + + const fetchInventory = async () => { + try { + const res = await fetch("/api/shopify/inventory") + if (!res.ok) throw new Error("Failed to fetch inventory") + const data = await res.json() + setProducts(data.products || []) + setError("") + } catch { + setError("Could not load inventory. Please ensure your Shopify store is connected and permissions are correct.") + } finally { + setLoading(false) + } + } + + const fetchProductSyncs = async () => { + try { + const res = await fetch("/api/products/sync") + if (res.ok) { + const data = await res.json() + const syncsMap = new Map() + data.syncs?.forEach((sync: ProductSync) => { + syncsMap.set(sync.shopifyProductId, sync) + }) + setProductSyncs(syncsMap) + } + } catch { + // Silent fail - syncs not critical for initial load + } + } + + const fetchConnectedSuppliers = async () => { + try { + // First get the active store + const storeRes = await fetch("/api/stores") + if (!storeRes.ok) return + + const storeData = await storeRes.json() + const activeStore = storeData.stores?.find((s: { isActive: boolean }) => s.isActive) + + if (!activeStore) { + setConnectedSuppliers([]) + return + } + + // Then get suppliers filtered by active store + const res = await fetch("/api/vendors/suppliers/my") + if (res.ok) { + const data = await res.json() + // Only show CONNECTED suppliers for the active store + + const connected = data.connections?.filter( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (c: any) => c.status === "CONNECTED" && + c.supplier && + c.store?.id === activeStore.id + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ).map((c: any) => c.supplier) || [] + + // Remove duplicates by supplier ID (in case of any edge cases) + const uniqueSuppliers = Array.from( + new Map(connected.map((s: Supplier) => [s.id, s])).values() + ) + + setConnectedSuppliers(uniqueSuppliers as Supplier[]) + } + } catch { + // Silent fail - suppliers not critical for initial load + } + } + + // Fetch store info, inventory, syncs, and suppliers + fetchStoreInfo() + fetchInventory() + fetchProductSyncs() + fetchConnectedSuppliers() + + // Auto-refresh every 10 seconds to catch webhook updates + const interval = setInterval(() => { + fetchInventory() + fetchProductSyncs() + fetchConnectedSuppliers() + }, 10000) // 10 seconds + + return () => clearInterval(interval) + }, []) + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (openMenuId !== null) { + const menuElement = menuRefs.current[openMenuId] + if (menuElement && !menuElement.contains(event.target as Node)) { + setOpenMenuId(null) + } + } + } + + if (openMenuId !== null) { + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + } + }, [openMenuId]) + + const handleMenuToggle = (productId: number) => { + setOpenMenuId(openMenuId === productId ? null : productId) + } + + const handleViewProduct = (product: ShopifyProduct) => { + if (storeDomain) { + window.open(`https://${storeDomain}/admin/products/${product.id}`, "_blank") + } else { + toast.error("Store domain not found. Please check your store configuration.") + } + setOpenMenuId(null) + } + + const handleCopyProductId = (productId: number) => { + navigator.clipboard.writeText(productId.toString()) + toast.success("Product ID copied to clipboard") + setOpenMenuId(null) + } + + const handleRefreshProduct = async () => { + // Trigger a refresh for products + try { + const res = await fetch("/api/shopify/inventory") + if (res.ok) { + const data = await res.json() + setProducts(data.products || []) + } + } catch { + // Silent fail + } + setOpenMenuId(null) + } + + const isProductSynced = (productId: number): ProductSync | null => { + const sync = productSyncs.get(productId.toString()) + return sync && sync.status === "ACCEPTED" ? sync : null + } + + const getProductSyncStatus = (productId: number): ProductSync | null => { + return productSyncs.get(productId.toString()) || null + } + + const handleSyncProduct = (product: ShopifyProduct) => { + if (connectedSuppliers.length === 0) { + toast.error("You need to connect with at least one supplier before syncing products. Please go to Suppliers > My Suppliers to connect.") + return + } + setSelectedProduct(product) + setShowSupplierModal(true) + } + + const handleSelectSupplier = async (supplierId: string) => { + if (!selectedProduct) return + + setSyncingProduct(true) + try { + const res = await fetch("/api/products/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + shopifyProductId: selectedProduct.id, + shopifyProductTitle: selectedProduct.title, + shopifyProductData: selectedProduct, + supplierId + }) + }) + + const data = await res.json() + + if (!res.ok) { + throw new Error(data.message || "Failed to send sync request") + } + + // Refresh syncs + const syncRes = await fetch("/api/products/sync") + if (syncRes.ok) { + const syncData = await syncRes.json() + const syncsMap = new Map() + syncData.syncs?.forEach((sync: ProductSync) => { + syncsMap.set(sync.shopifyProductId, sync) + }) + setProductSyncs(syncsMap) + } + + // Automatically close modal and reset state + setShowSupplierModal(false) + setSelectedProduct(null) + toast.success("Product sync request sent successfully! The supplier will be notified.") + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to send sync request" + toast.error(errorMessage) + } finally { + setSyncingProduct(false) + } + } + + const handleExport = () => { + const csvHeaders = ["ID", "Title", "Vendor", "Status", "Variants", "Created At"] + const csvRows = filteredProducts.map(p => [ + p.id, + `"${p.title.replace(/"/g, '""')}"`, + p.vendor, + p.status, + p.variants.length, + new Date(p.created_at).toLocaleDateString() + ]) + + const csvContent = [ + csvHeaders.join(","), + ...csvRows.map(row => row.join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const link = document.createElement("a") + const url = URL.createObjectURL(blob) + link.setAttribute("href", url) + link.setAttribute("download", `inventory-${new Date().toISOString().split("T")[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + toast.success("Inventory exported successfully") + } + + const filteredProducts = products.filter(p => { + const matchesSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()) || + p.vendor.toLowerCase().includes(searchTerm.toLowerCase()) + const matchesStatus = statusFilter === "all" || p.status === statusFilter + const matchesVendor = vendorFilter === "all" || p.vendor.toLowerCase() === vendorFilter.toLowerCase() + return matchesSearch && matchesStatus && matchesVendor + }) + + const uniqueVendors = Array.from(new Set(products.map(p => p.vendor))).sort() + + return ( +
+
+
+

Inventory

+

Manage your synced Shopify products and variants.

+
+
+ +
+
+ +
+
+
+ + setSearchTerm(e.target.value)} + className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+
+ + {showFilters && ( +
+
+
+ + +
+
+ + +
+ +
+
+ )} +
+
+ +
+
+ {loading ? ( +
+ +

Syncing with Shopify...

+
+ ) : error ? ( +
+
+ +
+
+

{error}

+

Check your Shopify API permissions (read_products) and try again.

+
+
+ ) : filteredProducts.length === 0 ? ( +
+ +

No products found.

+
+ ) : ( + + + + + + + + + + + + + {filteredProducts.map((product) => ( + + + + + + + + + ))} + +
ProductStatusInventorySupplierSyncActions
+
+
+ {product.image?.src ? ( + + ) : ( + + )} +
+
+ {product.title} + ID: {product.id} +
+
+
+ + {product.status} + + +
+ {product.variants.length} Variants + Total Sync Stock +
+
+ {(() => { + const sync = isProductSynced(product.id) + const syncStatus = getProductSyncStatus(product.id) + return sync ? ( + {sync.supplier.companyName} + ) : syncStatus?.status === "PENDING" ? ( + Pending... + ) : ( + — + ) + })()} + + {(() => { + const sync = isProductSynced(product.id) + const syncStatus = getProductSyncStatus(product.id) + return sync ? ( + + Synced + + ) : syncStatus?.status === "PENDING" ? ( + + Pending + + ) : ( + + ) + })()} + +
{ menuRefs.current[product.id] = el }}> + + {openMenuId === product.id && ( +
+ + + +
+ {storeDomain && ( + setOpenMenuId(null)} + className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-indigo-500 hover:text-white transition-colors flex items-center gap-2" + > + + View on Storefront + + )} +
+ )} +
+
+ )} +
+
+ + {/* Pagination removed as per requirements */} +
+ + {/* Supplier Selection Modal */} + {showSupplierModal && selectedProduct && ( +
+
+
+
+

Select Supplier

+

Choose a supplier to sync "{selectedProduct.title}"

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

No connected suppliers

+

Please connect with suppliers first from the Suppliers page.

+
+ ) : ( +
+ {connectedSuppliers.map((supplier) => ( + + ))} +
+ )} +
+
+
+ )} +
+ ) +} diff --git a/src/app/dashboard/juno-engine/email-templates/page.tsx b/src/app/dashboard/juno-engine/email-templates/page.tsx new file mode 100644 index 0000000..8bebcbd --- /dev/null +++ b/src/app/dashboard/juno-engine/email-templates/page.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" +import { + FileText, + Search, + Plus, + Loader2, + Edit, + Trash2, + Copy, + Eye +} from "lucide-react" + +interface EmailTemplate { + id: string + name: string + subject: string + category: "support" | "order" | "marketing" | "general" + updatedAt: string +} + +export default function EmailTemplatesPage() { + const [searchTerm, setSearchTerm] = useState("") + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(false) + + const getCategoryStyles = (category: string) => { + switch (category) { + case "support": return "bg-blue-500/20 text-blue-400" + case "order": return "bg-emerald-500/20 text-emerald-400" + case "marketing": return "bg-purple-500/20 text-purple-400" + case "general": return "bg-slate-500/20 text-slate-400" + default: return "bg-white/10 text-slate-400" + } + } + + return ( +
+
+
+
+ + setSearchTerm(e.target.value)} + className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+ +
+ +
+
+ {loading ? ( +
+ +

Loading templates...

+
+ ) : templates.length === 0 ? ( +
+ +

No email templates found.

+

Create your first template to get started.

+
+ ) : ( +
+ {templates.map((template) => ( +
+
+
+

+ {template.name} +

+

{template.subject}

+
+ + {template.category} + +
+ +
+ + Updated {new Date(template.updatedAt).toLocaleDateString()} + +
+ + + + +
+
+
+ ))} +
+ )} +
+
+
+
+ ) +} diff --git a/src/app/dashboard/juno-engine/flagged-email/page.tsx b/src/app/dashboard/juno-engine/flagged-email/page.tsx new file mode 100644 index 0000000..62c1265 --- /dev/null +++ b/src/app/dashboard/juno-engine/flagged-email/page.tsx @@ -0,0 +1,116 @@ +"use client" + +import { useState } from "react" +import { + Flag, + Search, + Filter, + Mail, + Loader2, + AlertCircle, + Trash2, + Archive, + CheckCircle2 +} from "lucide-react" + +interface FlaggedEmail { + id: string + subject: string + from: string + flaggedAt: string + reason: string + priority: "low" | "medium" | "high" +} + +export default function FlaggedEmailPage() { + const [searchTerm, setSearchTerm] = useState("") + const [flaggedEmails, setFlaggedEmails] = useState([]) + const [loading, setLoading] = useState(false) + + return ( +
+
+
+
+ + setSearchTerm(e.target.value)} + className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+
+ +
+
+ +
+
+ {loading ? ( +
+ +

Loading flagged emails...

+
+ ) : flaggedEmails.length === 0 ? ( +
+ +

No flagged emails found.

+

Emails flagged for review will appear here.

+
+ ) : ( + + + + + + + + + + + + {flaggedEmails.map((email) => ( + + + + + + + + ))} + +
SubjectFromReasonFlaggedActions
+
+ + {email.subject} +
+
+ {email.from} + + {email.reason} + + {new Date(email.flaggedAt).toLocaleDateString()} + +
+ + + +
+
+ )} +
+
+
+
+ ) +} diff --git a/src/app/dashboard/juno-engine/gmail-connect/page.tsx b/src/app/dashboard/juno-engine/gmail-connect/page.tsx new file mode 100644 index 0000000..5f75722 --- /dev/null +++ b/src/app/dashboard/juno-engine/gmail-connect/page.tsx @@ -0,0 +1,245 @@ +"use client" + +import { useState, useEffect, Suspense } from "react" +import { useSearchParams } from "next/navigation" +import toast from "react-hot-toast" +import { + Mail, + CheckCircle2, + Loader2, + Settings +} from "lucide-react" + +function GmailConnectContent() { + const searchParams = useSearchParams() + const [isConnected, setIsConnected] = useState(false) + const [connecting, setConnecting] = useState(false) + const [loading, setLoading] = useState(true) + const [gmailEmail, setGmailEmail] = useState(null) + + useEffect(() => { + const checkConnection = async () => { + try { + const res = await fetch("/api/integrations/gmail/status") + if (res.ok) { + const data = await res.json() + setIsConnected(data.connected || false) + setGmailEmail(data.email || null) + } + } catch { + // Silent fail + } finally { + setLoading(false) + } + } + checkConnection() + + // Check URL params for connection status + const connected = searchParams.get("connected") + const error = searchParams.get("error") + + if (connected === "true") { + setIsConnected(true) + setLoading(false) + toast.success("Gmail connected successfully!") + // Refresh connection status to get email + checkConnection() + } + if (error) { + const redirectUri = searchParams.get("redirect_uri") + const errorMessages: Record = { + oauth_failed: "OAuth authorization failed", + no_code: "Authorization code not received", + token_exchange_failed: "Failed to exchange authorization code", + user_info_failed: "Failed to fetch Gmail account info", + callback_failed: "Connection callback failed", + redirect_uri_mismatch: redirectUri + ? `Redirect URI mismatch. The redirect URI "${redirectUri}" must be added to your Google Cloud Console OAuth client. Go to Google Cloud Console > APIs & Services > Credentials > Your OAuth Client > Authorized redirect URIs and add this exact URI.` + : "Redirect URI mismatch. Please check your Google Cloud Console OAuth configuration." + } + const errorMessage = errorMessages[error] || "Failed to connect Gmail. Please try again." + toast.error(errorMessage, { duration: redirectUri ? 10000 : 5000 }) + setLoading(false) + } + }, [searchParams]) + + const handleConnect = async () => { + setConnecting(true) + try { + // Redirect to Gmail OAuth + const res = await fetch("/api/integrations/gmail/connect", { + method: "POST" + }) + const data = await res.json() + + if (res.ok && data.authUrl) { + window.location.href = data.authUrl + } else { + throw new Error(data.message || "Failed to initiate Gmail connection") + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to connect Gmail" + toast.error(errorMessage) + setConnecting(false) + } + } + + const handleDisconnect = async () => { + try { + const res = await fetch("/api/integrations/gmail/disconnect", { + method: "POST" + }) + if (res.ok) { + setIsConnected(false) + setGmailEmail(null) + toast.success("Gmail disconnected successfully") + } else { + const data = await res.json() + throw new Error(data.message || "Failed to disconnect") + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to disconnect Gmail" + toast.error(errorMessage) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+ +
+ +
+

+ {isConnected ? "Gmail Connected" : "Connect Your Gmail Account"} +

+

+ {isConnected + ? "Your Gmail account is connected and ready to sync emails." + : "Connect your Gmail account to enable email syncing, ticket creation, and automated responses." + } +

+
+ + {isConnected ? ( +
+
+ +
+

Connection Active

+

+ {gmailEmail ? `Connected to ${gmailEmail}` : "Emails are being synced automatically"} +

+
+
+ +
+ + +
+
+ ) : ( +
+
+
+ +
+

Email Sync

+

Automatically sync incoming emails

+
+
+
+ +
+

Ticket Creation

+

Create support tickets from emails

+
+
+
+ +
+

Auto Responses

+

Send automated replies using templates

+
+
+
+ + + +

+ By connecting, you authorize JUNOHUB to access your Gmail account for email management purposes. +

+
+ )} +
+
+
+ ) +} + +export default function GmailConnectPage() { + return ( + + +
+ }> + + + ) +} diff --git a/src/app/dashboard/juno-engine/knowledge-base/page.tsx b/src/app/dashboard/juno-engine/knowledge-base/page.tsx new file mode 100644 index 0000000..0ebffc4 --- /dev/null +++ b/src/app/dashboard/juno-engine/knowledge-base/page.tsx @@ -0,0 +1,125 @@ +"use client" + +import { useEffect, useState } from "react" +import { DocumentsSection } from "@/components/kb/DocumentsSection" +import { FaqSection } from "@/components/kb/FaqSection" +import { AskSection } from "@/components/kb/AskSection" + +export default function KnowledgeBasePage() { + const [activeStoreId, setActiveStoreId] = useState(null) + + useEffect(() => { + let cancelled = false + + ;(async () => { + try { + const res = await fetch("/api/stores") + if (!res.ok) return + + const data = (await res.json()) as { + stores?: { id: string; isActive: boolean }[] + } + const active = (data.stores || []).find((s) => s.isActive) + + if (!cancelled) { + setActiveStoreId(active?.id ?? null) + } + } catch { + if (!cancelled) { + setActiveStoreId(null) + } + } + })() + + return () => { + cancelled = true + } + }, []) + + const [activeTab, setActiveTab] = useState<"documents" | "faqs" | "ask">( + "documents", + ) + + const hasStore = Boolean(activeStoreId) + + return ( +
+
+
+

+ Knowledge Base +

+

+ Centralize your store's documents and FAQs so JUNO can + answer customer and vendor questions. +

+
+
+ + {!hasStore ? ( +
+
+

+ No active store selected +

+

+ Select a store from the top bar to manage its knowledge + base. +

+
+
+ ) : ( + <> +
+ + + +
+ +
+ {activeTab === "documents" && ( + + )} + {activeTab === "faqs" && ( + + )} + {activeTab === "ask" && ( + + )} +
+ + )} +
+ ) +} + diff --git a/src/app/dashboard/juno-engine/layout.tsx b/src/app/dashboard/juno-engine/layout.tsx new file mode 100644 index 0000000..e8b36b3 --- /dev/null +++ b/src/app/dashboard/juno-engine/layout.tsx @@ -0,0 +1,83 @@ +"use client" + +import { useRouter, usePathname } from "next/navigation" +import { + Ticket, + Flag, + Mail, + FileText, + HelpCircle +} from "lucide-react" + +export default function JunoEngineLayout({ + children, +}: { + children: React.ReactNode +}) { + const router = useRouter() + const pathname = usePathname() + + const tabs = [ + { + label: "Knowledge Base", + href: "/dashboard/juno-engine/knowledge-base", + icon: HelpCircle + }, + { + label: "Templates", + href: "/dashboard/juno-engine/email-templates", + icon: FileText + }, + { + label: "Tickets", + href: "/dashboard/juno-engine/tickets", + icon: Ticket + }, + { + label: "Flagged Emails", + href: "/dashboard/juno-engine/flagged-email", + icon: Flag + }, + { + label: "Gmail", + href: "/dashboard/juno-engine/gmail-connect", + icon: Mail + }, + ] + + return ( +
+
+
+

JUNO Engine

+

Manage your communication and support infrastructure.

+
+
+ +
+ {tabs.map((tab) => { + const isActive = pathname === tab.href + const Icon = tab.icon + return ( + + ) + })} +
+ +
+ {children} +
+
+ ) +} diff --git a/src/app/dashboard/juno-engine/page.tsx b/src/app/dashboard/juno-engine/page.tsx new file mode 100644 index 0000000..ed7db08 --- /dev/null +++ b/src/app/dashboard/juno-engine/page.tsx @@ -0,0 +1,15 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" + +export default function JunoEnginePage() { + const router = useRouter() + + useEffect(() => { + // Redirect to tickets page by default + router.replace("/dashboard/juno-engine/tickets") + }, [router]) + + return null +} diff --git a/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx b/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx new file mode 100644 index 0000000..d3b1d7a --- /dev/null +++ b/src/app/dashboard/juno-engine/tickets/[ticketId]/page.tsx @@ -0,0 +1,205 @@ +"use client" + +import { useEffect, useState } from "react" +import { useParams, useRouter } from "next/navigation" +import { ArrowLeft, Ticket, Loader2, MoreHorizontal } from "lucide-react" + +type TicketStatus = "open" | "pending" | "resolved" | "closed" + +interface TicketMessage { + id: string + subject: string + bodyPreview: string | null + status: string + trigger: string | null + sentAt: string +} + +interface TicketData { + id: string + subject: string + rootSubject: string + customerEmail: string + status: string + updatedAt: string + messagesCount: number + lastMessageSnippet: string + messages: TicketMessage[] +} + +export default function TicketDetailPage() { + const params = useParams<{ ticketId: string }>() + const router = useRouter() + const [ticket, setTicket] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadTicket = async () => { + try { + setLoading(true) + setError(null) + + const res = await fetch("/api/juno-engine/tickets", { + cache: "no-store", + }) + if (!res.ok) { + throw new Error("Failed to load ticket") + } + const data = (await res.json()) as { tickets: TicketData[] } + const decodedId = decodeURIComponent(params.ticketId) + const found = data.tickets.find((t) => t.id === decodedId) || null + if (!found) { + setError("Ticket not found.") + } else { + setTicket(found) + } + } catch (err) { + console.error("Failed to load ticket", err) + setError("Could not load this ticket. Please ensure your Gmail integration is connected.") + } finally { + setLoading(false) + } + } + + void loadTicket() + }, [params.ticketId]) + + const getStatusStyles = (status: TicketStatus | string) => { + switch (status) { + case "open": + return "bg-blue-500/20 text-blue-400" + case "pending": + return "bg-yellow-500/20 text-yellow-400" + case "resolved": + return "bg-emerald-500/20 text-emerald-400" + case "closed": + return "bg-slate-500/20 text-slate-400" + default: + return "bg-white/10 text-slate-400" + } + } + + return ( +
+ {/* Header */} +
+ +
+

+ Ticket Conversation +

+

+ Full automated email history for this customer. +

+
+
+ + {loading ? ( +
+ +

Loading ticket...

+
+ ) : error || !ticket ? ( +
+

{error || "Ticket not found."}

+
+ ) : ( +
+
+
+ {/* Ticket header */} +
+
+
+ +
+
+

+ {ticket.subject} +

+

+ {ticket.customerEmail} +

+
+
+ +
+ + {/* Summary */} +
+

{ticket.lastMessageSnippet}

+
+ + {/* Meta badges */} +
+
+ + {ticket.status} + + + {ticket.messagesCount} messages + +
+
+ Updated{" "} + {new Date(ticket.updatedAt).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} +
+
+ + {/* Conversation timeline */} +
+ {ticket.messages.map((message) => ( +
+
+
+ + {message.trigger || "AUTOMATED"} + + + {message.status} + +
+ + {new Date(message.sentAt).toLocaleString()} + +
+ {message.bodyPreview && ( +

+ {message.bodyPreview} +

+ )} +
+ ))} + + {ticket.messages.length === 0 && ( +

+ No messages logged yet for this ticket. +

+ )} +
+
+
+
+ )} +
+ ) +} + diff --git a/src/app/dashboard/juno-engine/tickets/page.tsx b/src/app/dashboard/juno-engine/tickets/page.tsx new file mode 100644 index 0000000..e083bad --- /dev/null +++ b/src/app/dashboard/juno-engine/tickets/page.tsx @@ -0,0 +1,194 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { + Ticket, + Search, + Filter, + Plus, + Loader2, + MoreHorizontal +} from "lucide-react" + +type TicketStatus = "open" | "pending" | "resolved" | "closed" + +interface TicketMessage { + id: string + subject: string + bodyPreview: string | null + status: string + trigger: string | null + sentAt: string +} + +interface TicketData { + id: string + subject: string + rootSubject: string + customerEmail: string + status: string + updatedAt: string + messagesCount: number + lastMessageSnippet: string + messages: TicketMessage[] +} + +export default function TicketsPage() { + const router = useRouter() + const [searchTerm, setSearchTerm] = useState("") + const [tickets, setTickets] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const loadTickets = async () => { + try { + setLoading(true) + setError(null) + const res = await fetch("/api/juno-engine/tickets", { + cache: "no-store", + }) + if (!res.ok) { + throw new Error("Failed to load tickets") + } + const data = (await res.json()) as { tickets: TicketData[] } + setTickets(data.tickets || []) + } catch (err) { + console.error("Failed to load tickets", err) + setError("Could not load tickets. Please ensure your Gmail integration is connected.") + } finally { + setLoading(false) + } + } + + void loadTickets() + }, []) + + const getStatusStyles = (status: TicketStatus | string) => { + switch (status) { + case "open": return "bg-blue-500/20 text-blue-400" + case "pending": return "bg-yellow-500/20 text-yellow-400" + case "resolved": return "bg-emerald-500/20 text-emerald-400" + case "closed": return "bg-slate-500/20 text-slate-400" + default: return "bg-white/10 text-slate-400" + } + } + + const filteredTickets = tickets.filter((ticket) => { + if (!searchTerm.trim()) return true + const q = searchTerm.toLowerCase() + return ( + ticket.subject.toLowerCase().includes(q) || + ticket.customerEmail.toLowerCase().includes(q) || + ticket.id.toLowerCase().includes(q) + ) + }) + + return ( +
+ {/* Header */} +
+
+

Tickets

+

+ View conversations between your customers and JUNOHUB support. +

+
+
+ + +
+
+ + {/* Search */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+
+ + {/* Tickets Grid */} +
+ {loading ? ( +
+ +

Loading tickets...

+
+ ) : error ? ( +
+

{error}

+
+ ) : filteredTickets.length === 0 ? ( +
+ +

No tickets found.

+

New conversations will appear here as they come in.

+
+ ) : ( +
+ {filteredTickets.map((ticket) => ( +
router.push(`/dashboard/juno-engine/tickets/${encodeURIComponent(ticket.id)}`)} + > + {/* Header */} +
+
+
+ +
+
+

{ticket.subject}

+

+ {ticket.customerEmail} +

+
+
+ +
+ + {/* Conversation snippet */} +
+

+ {ticket.lastMessageSnippet} +

+
+ + {/* Meta */} +
+
+ + {ticket.status} + + + {ticket.messagesCount} messages + +
+
+

Updated {new Date(ticket.updatedAt).toLocaleDateString()}

+
+
+ +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..4bc2bb4 --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -0,0 +1,77 @@ +import { Sidebar } from "@/components/Sidebar" +import { MobileSidebar } from "@/components/MobileSidebar" +import StoreSwitcher from "@/components/StoreSwitcher" +import UserAccountMenu from "@/components/UserAccountMenu" +import NotificationBell from "@/components/NotificationBell" +import PageTransition from "@/components/PageTransition" +import { auth } from "@/auth" +import { redirect } from "next/navigation" + +export default async function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const session = await auth() + + if (!session) { + redirect("/login") + } + + if (session.user.role !== "VENDOR") { + if (session.user.role === "SUPPLIER") { + redirect("/supplier") + } + redirect("/login") + } + + const { prisma } = await import("@/lib/prisma") + const storeWithOnboarding = await prisma.store.findFirst({ + where: { userId: session.user.id, onboardingComplete: true } + }) + + if (!storeWithOnboarding) { + redirect("/onboarding") + } + + return ( + // Root container fills viewport and only hides horizontal overflow +
+ {/* Desktop Sidebar */} +
+ +
+ + {/* Main Content Area */} +
+ {/* Fixed Header */} +
+
+ +

Vendor Portal

+
+ +
+
+
+ +
+ {session.user?.name || "Vendor User"} + {session.user?.email} +
+ +
+
+ + {/* Fixed-height content area; inner pages manage their own scrolling */} +
+ +
+ {children} +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/dashboard/new-products/page.tsx b/src/app/dashboard/new-products/page.tsx new file mode 100644 index 0000000..147d1c3 --- /dev/null +++ b/src/app/dashboard/new-products/page.tsx @@ -0,0 +1,152 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Package, + Loader2, + AlertCircle, + Image as ImageIcon, + Eye, + Building2, + Calendar +} from "lucide-react" +import Image from "next/image" + +interface SyncedProduct { + id: string + syncId: string + shopifyProductId: string + title: string + description: string | null + price: string + sku: string | null + imageUrl: string | null + supplier: { + id: string + companyName: string + } + syncedAt: string + isSynced: boolean + shopifyData?: any +} + +export default function NewProductsPage() { + const [products, setProducts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + + useEffect(() => { + fetchSyncedProducts() + }, []) + + const fetchSyncedProducts = async () => { + try { + setLoading(true) + const res = await fetch("/api/vendors/synced-products") + if (!res.ok) throw new Error("Failed to fetch synced products") + const data = await res.json() + setProducts(data.products || []) + setError("") + } catch { + setError("Failed to load synced products") + } finally { + setLoading(false) + } + } + + return ( +
+
+

New Products

+

Products synced with your suppliers

+
+ + {error && ( +
+ + {error} +
+ )} + +
+ {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ ) : products.length === 0 ? ( +
+ +

No synced products yet

+

Sync products with suppliers from the Inventory page

+
+ ) : ( +
+ {products.map((product) => ( +
+
+ {product.imageUrl ? ( + {product.title} + ) : ( +
+ +
+ )} +
+ + Synced + +
+
+
+

+ {product.title} +

+ + {product.description && ( +

+ {product.description.replace(/<[^>]*>/g, "")} +

+ )} + +
+
+

${product.price}

+ {product.sku && ( +

SKU: {product.sku}

+ )} +
+
+ +
+
+ + {product.supplier.companyName} +
+
+ + Synced {new Date(product.syncedAt).toLocaleDateString()} +
+
+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/dashboard/orders/[orderId]/page.tsx b/src/app/dashboard/orders/[orderId]/page.tsx new file mode 100644 index 0000000..6529423 --- /dev/null +++ b/src/app/dashboard/orders/[orderId]/page.tsx @@ -0,0 +1,362 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter, useParams } from "next/navigation" +import { ArrowLeft, ShoppingBag, User, Calendar, DollarSign, Package, MapPin, Phone, Mail, CheckCircle2, Clock, Loader2, AlertCircle } from "lucide-react" + +interface LineItem { + id: number + title: string + quantity: number + price: string + sku?: string + variant_title?: string +} + +interface OrderDetails { + id: number + name: string + order_number?: number + email: string + created_at: string + updated_at?: string + total_price: string + subtotal_price?: string + total_tax?: string + total_discounts?: string + currency: string + financial_status: string + fulfillment_status: string | null + customer: { + id?: number + first_name: string + last_name: string + email: string + phone?: string + } | null + shipping_address?: { + first_name: string + last_name: string + address1: string + address2?: string + city: string + province: string + country: string + zip: string + phone?: string + } + billing_address?: { + first_name: string + last_name: string + address1: string + address2?: string + city: string + province: string + country: string + zip: string + } + line_items: LineItem[] + note?: string + tags?: string +} + +export default function OrderDetailsPage() { + const router = useRouter() + const params = useParams() + const orderId = params?.orderId as string + const [order, setOrder] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + + useEffect(() => { + if (!orderId) return + + const fetchOrderDetails = async () => { + setLoading(true) + setError("") + try { + const res = await fetch(`/api/shopify/orders/${orderId}`) + if (!res.ok) throw new Error("Failed to fetch order details") + const data = await res.json() + setOrder(data.order) + } catch { + setError("Failed to load order details. Please try again.") + } finally { + setLoading(false) + } + } + + fetchOrderDetails() + }, [orderId]) + + if (loading) { + return ( +
+ +

Loading order details...

+
+ ) + } + + if (error) { + return ( +
+
+ +
+

{error}

+ +
+ ) + } + + if (!order) { + return ( +
+

Order not found

+ +
+ ) + } + + return ( +
+ {/* Header */} +
+ +
+
+ +
+
+

Order Details

+

{order.name}

+
+
+
+ + {/* Content */} +
+
+ {/* Order Summary & Status - Symmetric Top Grid */} +
+ {/* Order Date */} +
+
+ + Order Date +
+

+ {new Date(order.created_at).toLocaleString()} +

+
+ + {/* Total Amount */} +
+
+ + Total Amount +
+

+ {order.total_price} {order.currency} +

+
+ + {/* Payment Status */} +
+ + Payment Status + +
+ + {order.financial_status === "paid" && } + {order.financial_status} + +
+
+ + {/* Fulfillment Status */} +
+ + Fulfillment Status + +
+ + {order.fulfillment_status === "fulfilled" ? ( + + ) : ( + + )} + {order.fulfillment_status || "Unfulfilled"} + +
+
+
+ + {/* Main Content Grid */} +
+ {/* Left Column - Customer & Shipping */} +
+ {/* Customer Info */} + {order.customer && ( +
+
+ + Customer +
+
+

+ {order.customer.first_name} {order.customer.last_name} +

+
+ + {order.customer.email} +
+ {order.customer.phone && ( +
+ + {order.customer.phone} +
+ )} +
+
+ )} + + {/* Shipping Address */} + {order.shipping_address && ( +
+
+ + Shipping Address +
+
+

+ {order.shipping_address.first_name} {order.shipping_address.last_name} +

+

{order.shipping_address.address1}

+ {order.shipping_address.address2 &&

{order.shipping_address.address2}

} +

+ {order.shipping_address.city}, {order.shipping_address.province} {order.shipping_address.zip} +

+

{order.shipping_address.country}

+ {order.shipping_address.phone && ( +

+ + {order.shipping_address.phone} +

+ )} +
+
+ )} +
+
+ + {/* Centered Order Items Card */} +
+
+
+ + Order Items +
+
+ {order.line_items.map((item) => ( +
+
+
+

{item.title}

+ {item.variant_title && ( +

{item.variant_title}

+ )} +
+ + Qty: {item.quantity} + + {item.sku && ( + + SKU: {item.sku} + + )} +
+
+
+

+ {(parseFloat(item.price) * item.quantity).toFixed(2)} {order.currency} +

+

+ {parseFloat(item.price).toFixed(2)} {order.currency} each +

+
+
+
+ ))} +
+ + {/* Order Summary */} + {(order.subtotal_price || order.total_tax || (order.total_discounts && parseFloat(order.total_discounts) > 0)) && ( +
+ {order.subtotal_price && ( +
+ Subtotal + {order.subtotal_price} {order.currency} +
+ )} + {order.total_tax && ( +
+ Tax + {order.total_tax} {order.currency} +
+ )} + {order.total_discounts && parseFloat(order.total_discounts) > 0 && ( +
+ Discount + -{order.total_discounts} {order.currency} +
+ )} +
+ Total + {order.total_price} {order.currency} +
+
+ )} +
+ + {/* Order Notes */} + {order.note && ( +
+ Order Notes +

{order.note}

+
+ )} +
+
+
+
+ ) +} diff --git a/src/app/dashboard/orders/page.tsx b/src/app/dashboard/orders/page.tsx new file mode 100644 index 0000000..0be9dc0 --- /dev/null +++ b/src/app/dashboard/orders/page.tsx @@ -0,0 +1,355 @@ +"use client" + +import { useEffect, useState, useRef } from "react" +import { useRouter } from "next/navigation" +import toast from "react-hot-toast" +import { + ShoppingCart, + Search, + Filter, + Calendar, + Download, + Loader2, + AlertCircle, + ShoppingBag, + User, + CheckCircle2, + Clock, + MoreHorizontal, + Eye, + ExternalLink, + Copy, + ChevronLeft, + ChevronRight +} from "lucide-react" + +interface ShopifyOrder { + id: number + name: string + email: string + created_at: string + total_price: string + currency: string + financial_status: string + fulfillment_status: string | null + customer: { + first_name: string + last_name: string + email: string + } | null + line_items: unknown[] +} + +export default function OrdersPage() { + const router = useRouter() + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [searchTerm, setSearchTerm] = useState("") + const [openMenuId, setOpenMenuId] = useState(null) + const [storeDomain, setStoreDomain] = useState(null) + const [statusFilter, setStatusFilter] = useState("all") + const menuRefs = useRef<{ [key: number]: HTMLDivElement | null }>({}) + + useEffect(() => { + const fetchStoreInfo = async () => { + try { + const res = await fetch("/api/stores") + if (res.ok) { + const data = await res.json() + const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) + if (activeStore?.shopifyDomain) { + const cleanDomain = activeStore.shopifyDomain.replace(/^https?:\/\//, "").replace(/\/$/, "") + setStoreDomain(cleanDomain) + } + } + } catch { + // Silent fail + } + } + + const fetchOrders = async () => { + try { + const res = await fetch("/api/shopify/orders") + if (!res.ok) throw new Error("Failed to fetch orders") + const data = await res.json() + setOrders(data.orders || []) + setError("") + } catch { + setError("Could not load orders. Please ensure your Shopify store is connected.") + } finally { + setLoading(false) + } + } + + // Fetch store info and orders + fetchStoreInfo() + fetchOrders() + + // Auto-refresh every 15 seconds to catch webhook updates + const interval = setInterval(() => { + fetchOrders() + }, 15000) // 15 seconds + + return () => clearInterval(interval) + }, []) + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (openMenuId !== null) { + const menuElement = menuRefs.current[openMenuId] + if (menuElement && !menuElement.contains(event.target as Node)) { + setOpenMenuId(null) + } + } + } + + if (openMenuId !== null) { + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + } + }, [openMenuId]) + + const handleMenuToggle = (orderId: number) => { + setOpenMenuId(openMenuId === orderId ? null : orderId) + } + + const handleViewDetails = (order: ShopifyOrder) => { + router.push(`/dashboard/orders/${order.id}`) + setOpenMenuId(null) + } + + const handleViewInShopify = (orderId: number) => { + if (storeDomain) { + window.open(`https://${storeDomain}/admin/orders/${orderId}`, "_blank") + } else { + toast.error("Store domain not found. Please check your store configuration.") + } + setOpenMenuId(null) + } + + const handleCopyOrderId = (orderId: number) => { + navigator.clipboard.writeText(orderId.toString()) + toast.success("Order ID copied to clipboard") + setOpenMenuId(null) + } + + const handleExport = () => { + const csvHeaders = ["Order #", "Customer", "Email", "Date", "Total", "Currency", "Payment Status", "Fulfillment Status"] + const csvRows = filteredOrders.map(o => [ + o.name, + o.customer ? `"${o.customer.first_name} ${o.customer.last_name}"` : "", + o.email || "", + new Date(o.created_at).toLocaleDateString(), + o.total_price, + o.currency, + o.financial_status, + o.fulfillment_status || "unfulfilled" + ]) + + const csvContent = [ + csvHeaders.join(","), + ...csvRows.map(row => row.join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const link = document.createElement("a") + const url = URL.createObjectURL(blob) + link.setAttribute("href", url) + link.setAttribute("download", `orders-${new Date().toISOString().split("T")[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + toast.success("Orders exported successfully") + } + + const filteredOrders = orders.filter(o => { + const matchesSearch = o.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (o.customer && `${o.customer.first_name} ${o.customer.last_name}`.toLowerCase().includes(searchTerm.toLowerCase())) + const matchesStatus = statusFilter === "all" || + (statusFilter === "fulfilled" && o.fulfillment_status === "fulfilled") || + (statusFilter === "unfulfilled" && (!o.fulfillment_status || o.fulfillment_status !== "fulfilled")) || + (statusFilter === "paid" && o.financial_status === "paid") || + (statusFilter === "unpaid" && o.financial_status !== "paid") + return matchesSearch && matchesStatus + }) + + const getStatusStyles = (status: string | null) => { + switch (status) { + case "fulfilled": return "bg-emerald-500/20 text-emerald-400" + case "partial": return "bg-yellow-500/20 text-yellow-400" + default: return "bg-blue-500/20 text-blue-400" + } + } + + return ( +
+
+
+

Orders

+

Track and manage your Shopify orders for fulfillment.

+
+
+ + +
+
+ + {/* Search and Filter Controls */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full h-10 md:h-11 pl-10 md:pl-11 pr-4 bg-white/5 border border-white/10 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 text-white placeholder:text-slate-500 text-sm font-medium transition-all" + /> +
+
+ +
+
+ + {/* Orders Grid */} +
+ {loading ? ( +
+ +

Fetching latest orders...

+
+ ) : error ? ( +
+
+ +
+

{error}

+
+ ) : filteredOrders.length === 0 ? ( +
+ +

No orders found.

+
+ ) : ( +
+ {filteredOrders.map((order) => ( +
handleViewDetails(order)} + > + {/* Order Header */} +
+
+
+ +
+
+

{order.name}

+

+ {new Date(order.created_at).toLocaleDateString()} • {new Date(order.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +

+
+
+
e.stopPropagation()} ref={(el) => { menuRefs.current[order.id] = el }}> + + {openMenuId === order.id && ( +
+ + + +
+ )} +
+
+ + {/* Customer Info */} + {order.customer && ( +
+
+ +
+
+

+ {order.customer.first_name} {order.customer.last_name} +

+

{order.email}

+
+
+ )} + + {/* Order Details */} +
+
+ Total + {order.total_price} {order.currency} +
+ +
+ + {order.financial_status === "paid" && } + Payment: {order.financial_status} + + + {order.fulfillment_status === "fulfilled" ? : } + {order.fulfillment_status || "Unfulfilled"} + +
+
+
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..25a8bc1 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useEffect, useState } from "react" +import { + TrendingUp, + Package, + ShoppingCart, + Users, + ArrowUpRight, + Loader2 +} from "lucide-react" +import { + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + AreaChart, + Area +} from "recharts" + +export default function DashboardPage() { + const [stats, setStats] = useState([ + { label: "Total Sales", value: "$0.00", icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" }, + { label: "Active Products", value: "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" }, + { label: "Total Orders", value: "0", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-100" }, + { label: "Customers", value: "0", icon: Users, color: "text-orange-600", bg: "bg-orange-100" }, + ]) + const [chartData, setChartData] = useState<{ date: string; amount: number }[]>([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchDashboardData = async () => { + try { + const [productsRes, ordersRes, customersRes] = await Promise.all([ + fetch("/api/shopify/inventory").catch(() => ({ ok: false, json: async () => ({ products: [], message: "Failed to fetch products" }) })), + fetch("/api/shopify/orders").catch(() => ({ ok: false, json: async () => ({ orders: [], message: "Failed to fetch orders" }) })), + fetch("/api/shopify/customers").catch(() => ({ ok: false, json: async () => ({ customers: [], message: "Failed to fetch customers" }) })) + ]) + + const [productsData, ordersData, customersData] = await Promise.all([ + productsRes.json().catch(() => ({ products: [] })), + ordersRes.json().catch(() => ({ orders: [] })), + customersRes.json().catch(() => ({ customers: [] })) + ]) + + // Check for error messages + if (productsData.message && !productsRes.ok) { + console.warn("Products fetch error:", productsData.message) + } + if (ordersData.message && !ordersRes.ok) { + console.warn("Orders fetch error:", ordersData.message) + } + if (customersData.message && !customersRes.ok) { + console.warn("Customers fetch error:", customersData.message) + } + + const orders = ordersData.orders || [] + const totalSales = orders.reduce((sum: number, order: { total_price?: string }) => sum + parseFloat(order.total_price ?? "0"), 0) || 0 + + // Process chart data (Recent 7 days sales) + const salesByDate: Record = {} + orders.forEach((order: { created_at?: string; total_price?: string }) => { + const date = new Date(order.created_at ?? "").toLocaleDateString("en-US", { month: "short", day: "numeric" }) + salesByDate[date] = (salesByDate[date] || 0) + parseFloat(order.total_price ?? "0") + }) + + const processedChartData = Object.entries(salesByDate) + .map(([date, amount]) => ({ date, amount })) + .slice(-7) // Last 7 days + + setChartData(processedChartData) + + setStats([ + { label: "Total Sales", value: `$${totalSales.toLocaleString()}`, icon: TrendingUp, color: "text-indigo-600", bg: "bg-indigo-100" }, + { label: "Active Products", value: productsData.products?.length.toString() || "0", icon: Package, color: "text-emerald-600", bg: "bg-emerald-100" }, + { label: "Total Orders", value: orders.length.toString() || "0", icon: ShoppingCart, color: "text-blue-600", bg: "bg-blue-100" }, + { label: "Customers", value: customersData.customers?.length.toString() || "0", icon: Users, color: "text-orange-600", bg: "bg-orange-100" }, + ]) + } catch (error) { + console.error("Dashboard stats fetch error:", error) + } finally { + setLoading(false) + } + } + fetchDashboardData() + }, []) + + const statCardStyle: Record = { + "Total Sales": { bg: "bg-indigo-500/20 border-indigo-400/30", icon: "text-indigo-400" }, + "Active Products": { bg: "bg-emerald-500/20 border-emerald-400/30", icon: "text-emerald-400" }, + "Total Orders": { bg: "bg-blue-500/20 border-blue-400/30", icon: "text-blue-400" }, + "Customers": { bg: "bg-orange-500/20 border-orange-400/30", icon: "text-orange-400" }, + } + + return ( +
+
+
+

Overview

+

Welcome back! Here's a quick look at your live Shopify stats.

+
+ +
+ +
+ {stats.map((stat: (typeof stats)[number]) => ( +
+
+ {loading ? : } +
+
+

{stat.label}

+

+ {stat.value} +

+
+
+ ))} +
+ +
+

Recent Sales Trend

+
+ {loading ? ( +
+ +
+ ) : chartData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( +
+ No sales data found for the last 7 days. +
+ )} +
+
+
+ ) +} diff --git a/src/app/dashboard/products/page.tsx b/src/app/dashboard/products/page.tsx new file mode 100644 index 0000000..b0f2e39 --- /dev/null +++ b/src/app/dashboard/products/page.tsx @@ -0,0 +1,136 @@ +"use client" + +import { motion } from "framer-motion" +import { + Package, + Plus, + RefreshCcw, + MoreVertical, + AlertCircle, + Hash, + DollarSign, + Layers +} from "lucide-react" + +const mockInventory = [ + { id: "1", title: "Neo Wireless Controller", supplier: "Tokyo Tech Hub", price: "$45.00", stock: 120, status: "SYNCED" }, + { id: "2", title: "Smart Home Hub v2", supplier: "Shenzhen Direct", price: "$89.50", stock: 15, status: "LOW STOCK" }, + { id: "3", title: "Leather Tech Folder", supplier: "EuroLogistic SpA", price: "$120.00", stock: 45, status: "SYNCED" }, +] + +export default function InventoryPage() { + return ( +
+
+
+

Active Inventory

+

Manage products imported from your connected suppliers.

+
+ +
+ + +
+
+ +
+
+ + + + + + + + + + + + + {mockInventory.map((item, index) => ( + + + + + + + + + ))} + +
Product DetailsSupplierPriceStockStatus
+
+
+ +
+ {item.title} +
+
+ {item.supplier} + +
+ {item.price} +
+
+
+ + {item.stock} +
+
+ + {item.status} + + + +
+
+
+ + {/* Analytics Footer Placeholder */} +
+
+
+ +
+
+
Total Items
+
184
+
+
+
+
+ +
+
+
Inventory Value
+
$42,300
+
+
+
+
+ +
+
+
Stock Alerts
+
3 High Priority
+
+
+
+
+ ) +} diff --git a/src/app/dashboard/settings/account/page.tsx b/src/app/dashboard/settings/account/page.tsx new file mode 100644 index 0000000..f26e7be --- /dev/null +++ b/src/app/dashboard/settings/account/page.tsx @@ -0,0 +1,215 @@ +"use client" + +import { useState, useEffect } from "react" +import { useSession } from "next-auth/react" +import { User, Mail, Building, Save, Loader2, CheckCircle2, AlertCircle } from "lucide-react" + +export default function AccountSettingsPage() { + const { data: session, update } = useSession() + const [loading, setLoading] = useState(false) + const [success, setSuccess] = useState("") + const [error, setError] = useState("") + const [formData, setFormData] = useState({ + businessName: "", + currentPassword: "", + newPassword: "", + confirmPassword: "" + }) + + useEffect(() => { + // Fetch user's business name (from first store or user name) + const fetchBusinessName = async () => { + try { + const res = await fetch("/api/stores") + if (res.ok) { + const data = await res.json() + const activeStore = data.stores?.find((s: { isActive: boolean }) => s.isActive) + if (activeStore?.businessName) { + setFormData(prev => ({ ...prev, businessName: activeStore.businessName })) + } else if (session?.user?.name) { + setFormData(prev => ({ ...prev, businessName: session.user.name || "" })) + } + } + } catch { + // Silent fail + } + } + fetchBusinessName() + }, [session]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + setSuccess("") + + try { + // Validate password change if provided + if (formData.newPassword || formData.currentPassword) { + if (!formData.currentPassword) { + setError("Current password is required to change password") + setLoading(false) + return + } + if (formData.newPassword !== formData.confirmPassword) { + setError("New passwords do not match") + setLoading(false) + return + } + if (formData.newPassword.length < 8) { + setError("New password must be at least 8 characters and include upper, lower, number and special character") + setLoading(false) + return + } + } + + const updateData: { businessName?: string; currentPassword?: string; newPassword?: string } = {} + + if (formData.businessName) { + updateData.businessName = formData.businessName + } + + if (formData.newPassword) { + updateData.currentPassword = formData.currentPassword + updateData.newPassword = formData.newPassword + } + + const res = await fetch("/api/settings/account", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updateData) + }) + + if (!res.ok) { + const data = await res.json() + setError(data.message || "Failed to update account") + return + } + + setSuccess("Account updated successfully") + setFormData(prev => ({ ...prev, currentPassword: "", newPassword: "", confirmPassword: "" })) + + // Update session + await update() + } catch { + setError("An error occurred. Please try again.") + } finally { + setLoading(false) + } + } + + return ( +
+
+

Account Information

+

Update your business name and password.

+
+ + {success && ( +
+ + {success} +
+ )} + + {error && ( +
+ + {error} +
+ )} + +
+ {/* Email (Read-only) */} +
+ + +

Email cannot be changed

+
+ + {/* Business Name */} +
+ + setFormData(prev => ({ ...prev, businessName: e.target.value }))} + placeholder="Enter your business name" + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+ + {/* Password Change Section */} +
+ +
+
+ + setFormData(prev => ({ ...prev, currentPassword: e.target.value }))} + placeholder="Enter current password" + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+
+ + setFormData(prev => ({ ...prev, newPassword: e.target.value }))} + placeholder="Enter new password" + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+
+ + setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))} + placeholder="Confirm new password" + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+
+
+ +
+ +
+
+
+ ) +} diff --git a/src/app/dashboard/settings/layout.tsx b/src/app/dashboard/settings/layout.tsx new file mode 100644 index 0000000..3073c39 --- /dev/null +++ b/src/app/dashboard/settings/layout.tsx @@ -0,0 +1,70 @@ +"use client" + +import { User, Shield } from "lucide-react" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { cn } from "@/lib/utils" + +const settingsTabs = [ + { + id: "account", + label: "Account", + icon: User, + href: "/dashboard/settings/account" + }, + { + id: "security", + label: "Security", + icon: Shield, + href: "/dashboard/settings/security" + } +] + +export default function SettingsLayout({ + children, +}: { + children: React.ReactNode +}) { + const pathname = usePathname() + + return ( +
+
+

Settings

+

Manage your account and security preferences.

+
+ +
+ {/* Tabs */} +
+
+ {settingsTabs.map((tab) => { + const Icon = tab.icon + const isActive = pathname === tab.href + return ( + + + {tab.label} + + ) + })} +
+
+ + {/* Content */} +
+ {children} +
+
+
+ ) +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx new file mode 100644 index 0000000..6204806 --- /dev/null +++ b/src/app/dashboard/settings/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function SettingsPage() { + redirect("/dashboard/settings/account") +} diff --git a/src/app/dashboard/settings/security/page.tsx b/src/app/dashboard/settings/security/page.tsx new file mode 100644 index 0000000..617d11e --- /dev/null +++ b/src/app/dashboard/settings/security/page.tsx @@ -0,0 +1,427 @@ +"use client" + +import { useState, useEffect } from "react" +import { Shield, Smartphone, Monitor, MapPin, Clock, X, CheckCircle2, AlertCircle, Loader2, QrCode, Copy } from "lucide-react" +import Image from "next/image" + +interface Session { + id: string + sessionToken: string + expires: string + userAgent?: string + ipAddress?: string + isCurrent: boolean +} + +export default function SecuritySettingsPage() { + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(true) + const [mfaEnabled, setMfaEnabled] = useState(false) + const [mfaLoading, setMfaLoading] = useState(false) + const [setupLoading, setSetupLoading] = useState(false) + const [verifying, setVerifying] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [qrCode, setQrCode] = useState(null) + const [manualEntryKey, setManualEntryKey] = useState(null) + const [verificationCode, setVerificationCode] = useState("") + const [showSetup, setShowSetup] = useState(false) + + useEffect(() => { + fetchSessions() + fetchMFAStatus() + }, []) + + const fetchSessions = async () => { + try { + const res = await fetch("/api/settings/sessions") + if (res.ok) { + const data = await res.json() + setSessions(data.sessions || []) + } + } catch { + setError("Failed to load sessions") + } finally { + setLoading(false) + } + } + + const fetchMFAStatus = async () => { + try { + const res = await fetch("/api/settings/mfa/status") + if (res.ok) { + const data = await res.json() + setMfaEnabled(data.enabled || false) + } + } catch { + // Silent fail + } + } + + // Clear success/error messages after 5 seconds + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(""), 5000) + return () => clearTimeout(timer) + } + }, [success]) + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(""), 5000) + return () => clearTimeout(timer) + } + }, [error]) + + const handleSetupMFA = async () => { + setSetupLoading(true) + setError("") + setSuccess("") + try { + const res = await fetch("/api/settings/mfa/setup", { + method: "POST" + }) + if (res.ok) { + const responseData = await res.json() + setQrCode(responseData.qrCode) + setManualEntryKey(responseData.manualEntryKey) + setShowSetup(true) + } else { + const errorData = await res.json() + setError(errorData.message || "Failed to set up MFA") + } + } catch { + setError("An error occurred while setting up MFA") + } finally { + setSetupLoading(false) + } + } + + const handleVerifyAndEnable = async () => { + if (!verificationCode || verificationCode.length !== 6) { + setError("Please enter a valid 6-digit verification code") + return + } + + setVerifying(true) + setError("") + try { + const res = await fetch("/api/settings/mfa/verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: verificationCode }) + }) + if (res.ok) { + setMfaEnabled(true) + setShowSetup(false) + setQrCode(null) + setManualEntryKey(null) + setVerificationCode("") + setSuccess("MFA enabled successfully! You will be required to enter a verification code when signing in.") + } else { + const errorData = await res.json() + setError(errorData.message || "Invalid verification code") + } + } catch { + setError("An error occurred while verifying the code") + } finally { + setVerifying(false) + } + } + + const handleCopyKey = () => { + if (manualEntryKey) { + navigator.clipboard.writeText(manualEntryKey) + setSuccess("Secret key copied to clipboard") + setTimeout(() => setSuccess(""), 3000) + } + } + + const handleRevokeSession = async (sessionId: string) => { + try { + const res = await fetch(`/api/settings/sessions/${sessionId}`, { + method: "DELETE" + }) + if (res.ok) { + setSessions(prev => prev.filter(s => s.id !== sessionId)) + setSuccess("Session revoked successfully") + } else { + setError("Failed to revoke session") + } + } catch { + setError("An error occurred") + } + } + + const handleDisableMFA = async () => { + if (!confirm("Are you sure you want to disable MFA? This will reduce your account security.")) { + return + } + + setMfaLoading(true) + setError("") + try { + const res = await fetch("/api/settings/mfa/toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: false }) + }) + if (res.ok) { + setMfaEnabled(false) + setShowSetup(false) + setQrCode(null) + setManualEntryKey(null) + setSuccess("MFA disabled successfully") + } else { + const errorData = await res.json() + setError(errorData.message || "Failed to disable MFA") + } + } catch { + setError("An error occurred") + } finally { + setMfaLoading(false) + } + } + + const getDeviceIcon = (userAgent?: string) => { + if (!userAgent) return Monitor + if (userAgent.includes("Mobile") || userAgent.includes("Android") || userAgent.includes("iPhone")) { + return Smartphone + } + return Monitor + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString() + } + + return ( +
+
+

Security Settings

+

Manage your security preferences and active sessions.

+
+ + {success && ( +
+ + {success} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {/* MFA Section */} +
+
+
+
+ +

Multi-Factor Authentication (MFA)

+
+

+ Add an extra layer of security to your account by requiring a verification code in addition to your password. +

+
+ {mfaEnabled ? ( + + ) : ( + + )} +
+ + {/* MFA Setup Flow */} + {showSetup && !mfaEnabled && ( +
+
+

Step 1: Scan QR Code

+

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.) +

+ {qrCode && ( +
+
+ MFA QR Code +
+
+ +
+ + {manualEntryKey} + + +
+
+
+ )} +
+ +
+

Step 2: Verify Code

+

+ Enter the 6-digit code from your authenticator app to enable MFA +

+
+
+ { + const value = e.target.value.replace(/\D/g, "").slice(0, 6) + setVerificationCode(value) + }} + placeholder="000000" + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 text-center text-2xl font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + maxLength={6} + /> +
+ +
+
+
+ )} + + {mfaEnabled && ( +
+
+ +

+ MFA is currently enabled. You will be required to enter a verification code when signing in. +

+
+
+ )} +
+ + {/* Active Sessions */} +
+
+ +

Active Sessions

+
+

+ These are the devices currently logged into your account. Revoke any sessions you don't recognize. +

+ + {loading ? ( +
+ +
+ ) : sessions.length === 0 ? ( +
+

No active sessions found

+
+ ) : ( +
+ {sessions.map((sessionItem) => { + const DeviceIcon = getDeviceIcon(sessionItem.userAgent) + return ( +
+
+
+ +
+
+
+ + {sessionItem.userAgent || "Unknown Device"} + + {sessionItem.isCurrent && ( + + Current + + )} +
+
+ {sessionItem.ipAddress && ( +
+ + {sessionItem.ipAddress} +
+ )} +
+ + {formatDate(sessionItem.expires)} +
+
+
+
+ {!sessionItem.isCurrent && ( + + )} +
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/src/app/dashboard/stores/page.tsx b/src/app/dashboard/stores/page.tsx new file mode 100644 index 0000000..332f3f1 --- /dev/null +++ b/src/app/dashboard/stores/page.tsx @@ -0,0 +1,243 @@ +"use client" + +import { useEffect, useState } from "react" +import { Store, Plus, Check, Loader2, AlertCircle, Building2, Trash2 } from "lucide-react" +import AddStoreModal from "@/components/AddStoreModal" +import DeleteStoreModal from "@/components/DeleteStoreModal" + +interface StoreData { + id: string + businessName: string + shopifyDomain: string + isActive: boolean + onboardingComplete: boolean + createdAt: string +} + +export default function StoresPage() { + const [stores, setStores] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [switching, setSwitching] = useState(null) + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; storeId: string | null; storeName: string; isActive: boolean }>({ + isOpen: false, + storeId: null, + storeName: "", + isActive: false + }) + + useEffect(() => { + fetchStores() + }, []) + + const fetchStores = async () => { + try { + const res = await fetch("/api/stores") + if (!res.ok) throw new Error("Failed to fetch stores") + const data = await res.json() + setStores(data.stores || []) + } catch { + setError("Could not load stores") + } finally { + setLoading(false) + } + } + + const handleSwitchStore = async (storeId: string) => { + setSwitching(storeId) + try { + const res = await fetch("/api/stores/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ storeId }) + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({ message: "Failed to switch store" })) + alert(data.message || "Failed to switch store. Please ensure the store has valid Shopify credentials.") + setSwitching(null) + return + } + + // Refresh the page to reload data for the new active store + window.location.reload() + } catch (err) { + console.error("Switch store error:", err) + alert("An error occurred while switching stores. Please try again.") + setSwitching(null) + } + } + + const handleAddStore = () => { + setIsAddModalOpen(true) + } + + const handleStoreAdded = () => { + fetchStores() + // Notify StoreSwitcher to refresh + window.dispatchEvent(new CustomEvent("storeUpdated")) + } + + const handleDeleteClick = (store: StoreData) => { + setDeleteModal({ + isOpen: true, + storeId: store.id, + storeName: store.businessName, + isActive: store.isActive + }) + } + + const handleDeleteConfirm = async () => { + if (!deleteModal.storeId) return + + try { + const res = await fetch(`/api/stores/${deleteModal.storeId}`, { + method: "DELETE" + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({ message: "Failed to delete store" })) + throw new Error(data.message || "Failed to delete store") + } + + // Refresh stores list + await fetchStores() + + // Notify StoreSwitcher to refresh + window.dispatchEvent(new CustomEvent("storeUpdated")) + + // If deleted store was active, reload page to refresh dashboard data + if (deleteModal.isActive) { + window.location.reload() + } + } catch (err) { + throw err + } + } + + return ( +
+
+
+

Stores

+

Manage your connected Shopify stores.

+
+ +
+ + {loading ? ( +
+ +

Loading stores...

+
+ ) : error ? ( +
+ +

{error}

+
+ ) : stores.length === 0 ? ( +
+
+
+ +
+
+

No Stores Connected

+

+ Connect your first Shopify store to get started. +

+
+ +
+
+ ) : ( +
+ {stores.map((store) => ( +
+
+
+ +
+ {store.isActive && ( + + + Active + + )} +
+ +
+

{store.businessName}

+

{store.shopifyDomain}

+

+ Connected {new Date(store.createdAt).toLocaleDateString()} +

+
+ +
+ {!store.isActive ? ( + + ) : ( +
+ )} + +
+
+ ))} +
+ )} + + setIsAddModalOpen(false)} + onSuccess={handleStoreAdded} + /> + + setDeleteModal({ isOpen: false, storeId: null, storeName: "", isActive: false })} + onConfirm={handleDeleteConfirm} + storeName={deleteModal.storeName} + isActive={deleteModal.isActive} + /> +
+ ) +} diff --git a/src/app/dashboard/supplier/products/page.tsx b/src/app/dashboard/supplier/products/page.tsx new file mode 100644 index 0000000..a78be0c --- /dev/null +++ b/src/app/dashboard/supplier/products/page.tsx @@ -0,0 +1,108 @@ +"use client" + +import { useSession } from "next-auth/react" +import { motion } from "framer-motion" +import { + Package, + Plus, + Search, + Filter, + Edit, + Trash2, + Eye +} from "lucide-react" + +const mockProducts = [ + { id: "1", title: "Wireless Noise Cancelling Headphones", category: "Electronics", price: "$45.00", stock: 1200, status: "PUBLISHED" }, + { id: "2", title: "Ergonomic Mechanical Keyboard", category: "Electronics", price: "$65.00", stock: 450, status: "DRAFT" }, + { id: "3", title: "Smart LED Desk Lamp", category: "Home", price: "$22.50", stock: 800, status: "PUBLISHED" }, +] + +export default function SupplierProductsPage() { + useSession() + + return ( +
+
+
+

Product Source

+

Manage your global inventory and distribution catalog.

+
+ +
+ +
+
+ +
+
+ + +
+ +
+ +
+ {mockProducts.map((product, index) => ( + +
+ +
+ +
+
+

{product.title}

+ + {product.status} + +
+
+ SKU: {product.id}00-TX + + {product.category} +
+
+ +
+
Unit Price
+
{product.price}
+
+ +
+
In Stock
+
{product.stock}
+
+ +
+ + + +
+
+ ))} +
+
+ ) +} diff --git a/src/app/dashboard/suppliers/global/page.tsx b/src/app/dashboard/suppliers/global/page.tsx new file mode 100644 index 0000000..3d31122 --- /dev/null +++ b/src/app/dashboard/suppliers/global/page.tsx @@ -0,0 +1,366 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Package, + Search, + Mail, + Loader2, + AlertCircle, + CheckCircle2, + Clock, + XCircle, + Building2, + Users, + X, + Store +} from "lucide-react" +import Image from "next/image" + +interface Supplier { + id: string + companyName: string + description: string | null + createdAt: string + connectionStatus: "NONE" | "PENDING" | "CONNECTED" | "REJECTED" + user: { + id: string + name: string | null + email: string | null + } + _count: { + products: number + connections: number + } +} + +export default function GlobalSuppliersPage() { + const [suppliers, setSuppliers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [searchTerm, setSearchTerm] = useState("") + const [invitingId, setInvitingId] = useState(null) + const [showInviteModal, setShowInviteModal] = useState(false) + const [selectedSupplierId, setSelectedSupplierId] = useState(null) + const [connectToAllStores, setConnectToAllStores] = useState(false) + + useEffect(() => { + fetchSuppliers() + }, []) + + // Poll for updates every 5 seconds if there are pending suppliers + useEffect(() => { + const hasPending = suppliers.some(s => s.connectionStatus === "PENDING") + if (!hasPending) return // Don't poll if no pending suppliers + + const interval = setInterval(() => { + fetchSuppliers() + }, 5000) // Poll every 5 seconds + + return () => clearInterval(interval) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [suppliers]) // Re-run when suppliers change + + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(""), 5000) + return () => clearTimeout(timer) + } + }, [success]) + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(""), 5000) + return () => clearTimeout(timer) + } + }, [error]) + + const fetchSuppliers = async () => { + try { + setLoading(true) + const res = await fetch("/api/vendors/suppliers") + if (!res.ok) throw new Error("Failed to fetch suppliers") + const data = await res.json() + setSuppliers(data.suppliers || []) + setError("") + } catch { + setError("Failed to load suppliers") + } finally { + setLoading(false) + } + } + + const handleSendInvitation = async (supplierId: string, connectAll: boolean = false) => { + setInvitingId(supplierId) + setError("") + setShowInviteModal(false) + try { + const res = await fetch(`/api/vendors/suppliers/${supplierId}/invite`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + connectToAllStores: connectAll + }) + }) + const data = await res.json() + if (!res.ok) throw new Error(data.message || "Failed to send invitation") + + const message = connectAll && data.storesConnected > 1 + ? `Invitations sent successfully to ${data.storesConnected} store(s)! The supplier will receive an email with invitation tokens.` + : "Invitation sent successfully! The supplier will receive an email with an invitation token." + + setSuccess(message) + fetchSuppliers() // Refresh to update connection status + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send invitation") + } finally { + setInvitingId(null) + setSelectedSupplierId(null) + setConnectToAllStores(false) + } + } + + const openInviteModal = (supplierId: string) => { + setSelectedSupplierId(supplierId) + setShowInviteModal(true) + } + + const getStatusBadge = (status: string) => { + switch (status) { + case "CONNECTED": + return ( + + + Connected + + ) + case "PENDING": + return ( + + + Pending + + ) + case "REJECTED": + return ( + + + Rejected + + ) + default: + return null + } + } + + const filteredSuppliers = suppliers.filter((supplier) => + supplier.companyName.toLowerCase().includes(searchTerm.toLowerCase()) || + supplier.description?.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + return ( +
+
+

Global Suppliers

+

Connect with suppliers from around the world.

+
+ + {success && ( +
+ + {success} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+ + {loading ? ( +
+ +
+ ) : filteredSuppliers.length === 0 ? ( +
+ +

+ {searchTerm ? "No suppliers found matching your search" : "No suppliers available yet"} +

+
+ ) : ( +
+ {filteredSuppliers.map((supplier) => ( +
+
+
+
+ +
+
+

{supplier.companyName}

+ {supplier.user.name && ( +

{supplier.user.name}

+ )} +
+
+
+ {getStatusBadge(supplier.connectionStatus)} +
+
+ + {supplier.description && ( +

{supplier.description}

+ )} + +
+
+ + {supplier._count.products} products +
+
+ + {supplier._count.connections} connections +
+ {supplier.user.email && ( +
+ + {supplier.user.email} +
+ )} +
+ + {supplier.connectionStatus === "NONE" && ( + + )} +
+ ))} +
+ )} + + {/* Invite Modal */} + {showInviteModal && selectedSupplierId && ( +
+
+
+

Send Invitation

+ +
+ +

+ Choose how you want to connect with this supplier: +

+ +
+ + + +
+ +
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/app/dashboard/suppliers/my/page.tsx b/src/app/dashboard/suppliers/my/page.tsx new file mode 100644 index 0000000..bdfbf70 --- /dev/null +++ b/src/app/dashboard/suppliers/my/page.tsx @@ -0,0 +1,285 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Users, + Loader2, + AlertCircle, + CheckCircle2, + Clock, + XCircle, + Building2, + Mail, + Package, + Globe, + X, + Store +} from "lucide-react" + +interface Connection { + id: string + status: "PENDING" | "CONNECTED" | "REJECTED" + createdAt: string + supplier: { + id: string + companyName: string + description: string | null + user: { + name: string | null + email: string | null + } + _count: { + products: number + } + } + store?: { + id: string + businessName: string + isActive: boolean + } +} + +export default function MySuppliersPage() { + const [connections, setConnections] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [filter, setFilter] = useState<"ALL" | "PENDING" | "CONNECTED" | "REJECTED">("ALL") + const [cancellingId, setCancellingId] = useState(null) + + useEffect(() => { + fetchMySuppliers() + }, []) + + // Poll for updates every 5 seconds if there are pending connections + useEffect(() => { + const hasPending = connections.some(c => c.status === "PENDING") + if (!hasPending) return // Don't poll if no pending connections + + const interval = setInterval(() => { + fetchMySuppliers() + }, 5000) // Poll every 5 seconds + + return () => clearInterval(interval) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connections]) // Re-run when connections change + + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(""), 5000) + return () => clearTimeout(timer) + } + }, [success]) + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(""), 5000) + return () => clearTimeout(timer) + } + }, [error]) + + const fetchMySuppliers = async () => { + try { + setLoading(true) + const res = await fetch("/api/vendors/suppliers/my") + if (!res.ok) throw new Error("Failed to fetch suppliers") + const data = await res.json() + setConnections(data.connections || []) + setError("") + } catch { + setError("Failed to load suppliers") + } finally { + setLoading(false) + } + } + + const handleCancelInvitation = async (supplierId: string) => { + setCancellingId(supplierId) + setError("") + try { + const res = await fetch(`/api/vendors/suppliers/${supplierId}/invite`, { + method: "DELETE" + }) + const data = await res.json() + if (!res.ok) throw new Error(data.message || "Failed to cancel invitation") + setSuccess("Invitation cancelled successfully. You can now send a new invitation.") + fetchMySuppliers() // Refresh the list + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to cancel invitation") + } finally { + setCancellingId(null) + } + } + + const filteredConnections = connections.filter((conn) => + filter === "ALL" ? true : conn.status === filter + ) + + const getStatusBadge = (status: string) => { + switch (status) { + case "CONNECTED": + return ( + + + Connected + + ) + case "PENDING": + return ( + + + Pending + + ) + case "REJECTED": + return ( + + + Rejected + + ) + default: + return null + } + } + + return ( +
+
+

My Suppliers

+

Manage your existing supplier connections.

+
+ + {success && ( +
+ + {success} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {/* Filter Tabs */} +
+ {(["ALL", "PENDING", "CONNECTED", "REJECTED"] as const).map((status) => ( + + ))} +
+ + {loading ? ( +
+ +
+ ) : filteredConnections.length === 0 ? ( +
+ +

No Connected Suppliers

+

+ {filter === "ALL" + ? "You haven't connected with any suppliers yet. Browse the global marketplace to find suppliers." + : `No ${filter.toLowerCase()} suppliers`} +

+
+ ) : ( +
+ {filteredConnections.map((connection) => ( +
+
+
+
+ +
+
+

{connection.supplier.companyName}

+ {connection.supplier.user.name && ( +

{connection.supplier.user.name}

+ )} +
+
+
+ {getStatusBadge(connection.status)} +
+
+ + {connection.supplier.description && ( +

{connection.supplier.description}

+ )} + +
+
+ + {connection.supplier._count.products} products +
+ {connection.supplier.user.email && ( +
+ + {connection.supplier.user.email} +
+ )} +
+ +
+
+
+ {connection.status === "PENDING" ? "Invited" : "Connected"}: {new Date(connection.createdAt).toLocaleDateString()} +
+ {connection.status === "PENDING" && ( + + )} +
+ {connection.store && ( +
+ + {connection.store.businessName} + {!connection.store.isActive && ( + Inactive + )} +
+ )} +
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/app/dashboard/suppliers/page.tsx b/src/app/dashboard/suppliers/page.tsx new file mode 100644 index 0000000..4a3d465 --- /dev/null +++ b/src/app/dashboard/suppliers/page.tsx @@ -0,0 +1,52 @@ +"use client" + +import { Globe } from "lucide-react" +import { useRouter } from "next/navigation" + +export default function SuppliersPage() { + const router = useRouter() + + return ( +
+
+
+

Suppliers

+

Discover and manage your supply chain partners.

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

Expand Your Network

+

Browse our verified global supply chain to find high-quality products for your store.

+
+ +
+
+
+ ) +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..7f7f36f --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,241 @@ +@import "tailwindcss"; + +@theme { + --color-indigo-accent: #4f46e5; + --color-indigo-secondary: #f5f3ff; + --color-slate-custom: #f8fafc; +} + +:root { + --background: #f8fafc; + --foreground: #0f172a; + --primary: var(--color-indigo-accent); + /* Glassmorphic dark mode */ + --glass-bg: rgba(15, 23, 42, 0.6); + --glass-border: rgba(255, 255, 255, 0.08); + --glass-card: rgba(255, 255, 255, 0.05); + --dashboard-bg: #0a0e1a; + --dashboard-mesh: radial-gradient(ellipse 80% 50% at 50% -20%, rgba(79, 70, 229, 0.15), transparent), + radial-gradient(ellipse 60% 40% at 100% 50%, rgba(30, 58, 138, 0.08), transparent), + radial-gradient(ellipse 50% 30% at 0% 80%, rgba(16, 185, 129, 0.06), transparent); +} + +body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +/* Dashboard glassmorphic dark container */ +.dashboard-glass-dark { + min-height: 100vh; + background-color: var(--dashboard-bg); + background-image: var(--dashboard-mesh); +} + +/* Auth pages (login, signup) - same dark glass background */ +.auth-glass-dark { + min-height: 100vh; + background-color: var(--dashboard-bg); + background-image: var(--dashboard-mesh); +} + +/* Glassmorphism helpers */ +.glass { + background: var(--glass-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--glass-border); +} + +.glass-card { + background: var(--glass-card); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); +} + +.glow-emerald { + box-shadow: 0 0 20px rgba(16, 185, 129, 0.2); +} + +.glow-lime { + box-shadow: 0 0 20px rgba(132, 204, 22, 0.2); +} + +/* Smooth scrolling */ +html { + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; +} + +/* Optimize scroll performance */ +.scroll-smooth { + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + will-change: scroll-position; + transform: translateZ(0); + -webkit-transform: translateZ(0); +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} + +::-webkit-scrollbar-track { + background: var(--dashboard-bg, #0a0e1a); +} + +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(16, 185, 129, 0.5); +} + +/* Smooth scrolling container optimization */ +[data-scroll-container] { + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + transform: translateZ(0); + -webkit-transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +/* Optimize backdrop blur performance during scroll */ +[data-scroll-container] .backdrop-blur-xl { + will-change: auto; +} + +/* Smooth transitions for all interactive elements */ +* { + transition-property: background-color, border-color, color, fill, stroke, opacity, box-shadow, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +/* Optimize performance for animations */ +button, a, input, select, textarea { + will-change: transform; +} + +/* Smooth page transitions */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.page-transition { + animation: fadeIn 0.3s ease-out; +} + +/* Smooth sidebar transitions */ +.sidebar-transition { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease; +} + +/* Optimize backdrop blur performance */ +.backdrop-blur-xl { + will-change: backdrop-filter; + transform: translateZ(0); +} + +/* Smooth table row hover */ +tbody tr { + transition: background-color 0.15s ease, transform 0.15s ease; +} + +tbody tr:hover { + transform: translateX(2px); +} + +/* Smooth card hover effects */ +.card-hover { + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; +} + +.card-hover:hover { + transform: translateY(-2px); +} + +/* Smooth button transitions */ +button { + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +button:active { + transform: scale(0.98); +} + +/* Smooth input focus transitions */ +input, textarea, select { + transition: all 0.2s ease; +} + +/* Smooth modal/dropdown animations */ +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.modal-enter { + animation: slideDown 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Smooth loading spinner */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +/* GPU acceleration for smooth animations */ +.gpu-accelerated { + transform: translateZ(0); + will-change: transform; +} + +/* Smooth opacity transitions */ +.fade-in { + animation: fadeIn 0.3s ease-out; +} + +/* Reduce motion for users who prefer it */ +@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; + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..d5e7984 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "JUNOHUB", + description: "JUNOHUB - Your dropshipping and e-commerce management platform", + icons: { + icon: "/icon.svg", + apple: "/icon.svg", + }, +}; + +import { Providers } from "@/components/providers"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + {children} + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..bfbc502 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,275 @@ +"use client" + +import { signIn } from "next-auth/react" +import { useState } from "react" +import { useRouter } from "next/navigation" +import { motion } from "framer-motion" +import { Mail, Lock, Loader2, Zap, Shield } from "lucide-react" +import Link from "next/link" + +export default function LoginPage() { + const router = useRouter() + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [requiresMFA, setRequiresMFA] = useState(false) + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [mfaCode, setMfaCode] = useState("") + + const handleCredentialsLogin = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + // Get values from form (FormData works even with controlled inputs) + const formData = new FormData(e.currentTarget) + const emailFromForm = (formData.get("email") as string)?.trim() || "" + const passwordFromForm = (formData.get("password") as string) || "" + const mfaCodeValue = (formData.get("mfaCode") as string)?.trim() || "" + + // Use form values, fallback to state if form is empty (shouldn't happen with controlled inputs) + const emailValue = emailFromForm || email.trim() + const passwordValue = passwordFromForm || password + + // Update state to keep it in sync + if (emailFromForm && emailFromForm !== email) setEmail(emailFromForm) + if (passwordFromForm && passwordFromForm !== password) setPassword(passwordFromForm) + + // Validate inputs + if (!emailValue || !passwordValue) { + setError("Please enter both email and password") + setLoading(false) + return + } + + try { + // First, check credentials and MFA status + const loginRes = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: emailValue, + password: passwordValue, + mfaCode: mfaCodeValue || undefined + }) + }) + + const loginData = await loginRes.json() + + if (!loginRes.ok) { + setError(loginData.message || "Invalid email or password") + setRequiresMFA(false) + return + } + + // If MFA is required and code not provided, show MFA input + if (loginData.requiresMFA && !mfaCodeValue) { + setRequiresMFA(true) + return + } + + // If MFA was required and code provided, or MFA not required, proceed with NextAuth signIn + const res = await signIn("credentials", { + email: emailValue, + password: passwordValue, + redirect: false, + callbackUrl: "/onboarding", + }) + + if (res?.error) { + setError("Authentication failed. Please try again.") + setRequiresMFA(false) + } else { + // Redirect based on user role - middleware will handle the final routing + // For now, just refresh and let middleware redirect appropriately + router.refresh() + } + } catch { + setError("An error occurred. Please try again.") + setRequiresMFA(false) + } finally { + setLoading(false) + } + } + + const handleGoogleSignIn = () => { + signIn("google", { callbackUrl: "/onboarding" }) + } + + return ( +
+ +
+
+
+
+ +
+

JUNOHUB

+
+

+ Welcome Back +

+

+ Access your dropshipping empire. +

+
+ +
+
+
+ +
+ + setEmail(e.target.value)} + placeholder="john@example.com" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200 disabled:opacity-50" + /> +
+
+ +
+ +
+ + { + setPassword(e.target.value) + }} + placeholder="••••••••" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200 disabled:opacity-50" + /> +
+
+ + {requiresMFA && ( + + +
+ + { + const value = e.target.value.replace(/\D/g, "").slice(0, 6) + setMfaCode(value) + }} + placeholder="000000" + className="w-full h-12 bg-white/5 border border-indigo-500/30 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 transition-all duration-200 text-center text-2xl font-mono tracking-widest" + maxLength={6} + autoFocus + /> +
+

+ Enter the 6-digit code from your authenticator app +

+
+ )} +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +
+
+
+
+
+ Or continue with +
+
+ + + +

+ Don't have an account?{" "} + + Create an account + +

+
+ +
+
+ JUNOHUB Systems © 2026 +
+
+ + Privacy Policy + + • + + Terms of Service + +
+
+
+
+ ) +} diff --git a/src/app/onboarding/onboarding-client.tsx b/src/app/onboarding/onboarding-client.tsx new file mode 100644 index 0000000..859967c --- /dev/null +++ b/src/app/onboarding/onboarding-client.tsx @@ -0,0 +1,416 @@ +"use client" + +import { useState } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { + Building2, + ShoppingBag, + Settings2, + CheckCircle2, + Clock3, + ArrowRight, + ArrowLeft, + Loader2, + AlertCircle, + Zap, + Layout, + User +} from "lucide-react" +import { useRouter } from "next/navigation" + +const steps = [ + { id: 1, title: "Business Info", icon: Building2 }, + { id: 2, title: "Connect Shopify", icon: ShoppingBag }, + { id: 3, title: "Preferences", icon: Settings2 }, + { id: 4, title: "Finalize", icon: CheckCircle2 }, +] + +export default function OnboardingClient() { + const [currentStep, setCurrentStep] = useState(1) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const router = useRouter() + + const [formData, setFormData] = useState({ + businessName: "", + businessEmail: "", + country: "", + timezone: "", + storeName: "", + domain: "", + apiKey: "", + apiSecret: "", + accessToken: "", + syncFrequency: "daily", + autoFulfill: false, + }) + + const updateFormData = (fields: Partial) => { + setFormData(prev => ({ ...prev, ...fields })) + } + + const handleNext = () => { + if (currentStep < 4) setCurrentStep(prev => prev + 1) + } + + const handleBack = () => { + if (currentStep > 1) setCurrentStep(prev => prev - 1) + } + + const validateShopify = async () => { + if (!formData.domain || !formData.accessToken) { + setError("Domain and Access Token are required.") + return + } + + setLoading(true) + setError("") + try { + const res = await fetch("/api/shopify/validate", { + method: "POST", + body: JSON.stringify({ + storeName: formData.storeName, + domain: formData.domain, + apiKey: formData.apiKey, + apiSecret: formData.apiSecret, + accessToken: formData.accessToken + }), + headers: { "Content-Type": "application/json" } + }) + + if (res.ok) { + handleNext() + } else { + const data = await res.json() + setError(data.message || "Failed to validate Shopify credentials.") + } + } catch { + setError("An error occurred during validation. Please try again.") + } finally { + setLoading(false) + } + } + + const completeOnboarding = async () => { + setLoading(true) + setError("") + try { + const res = await fetch("/api/onboarding/complete", { + method: "POST", + body: JSON.stringify(formData), + headers: { "Content-Type": "application/json" } + }) + + if (res.ok) { + handleNext() + } else { + const data = await res.json() + setError(data.message || "Failed to save onboarding data.") + } + } catch { + setError("An error occurred. Please try again.") + } finally { + setLoading(false) + } + } + + const renderStep = () => { + switch (currentStep) { + case 1: + return ( + +
+
+ + updateFormData({ businessName: e.target.value })} + placeholder="Acme Global" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ + updateFormData({ businessEmail: e.target.value })} + placeholder="contact@acme.com" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ + +
+
+ + +
+
+
+ ) + case 2: + return ( + +
+ +
+

How to get your credentials?

+ Go to your Shopify Admin > Settings > Apps and sales channels > Develop apps. Create an app and configure Admin API scopes. +
+
+
+
+ + updateFormData({ storeName: e.target.value })} + placeholder="My Store" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ + updateFormData({ domain: e.target.value })} + placeholder="mystore.myshopify.com" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ + updateFormData({ apiKey: e.target.value })} + placeholder="••••••••••••" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ + updateFormData({ apiSecret: e.target.value })} + placeholder="••••••••••••" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ + updateFormData({ accessToken: e.target.value })} + placeholder="shpat_••••••••••••" + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl px-4 text-white placeholder:text-slate-500 focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50 outline-none transition-all" + /> +
+
+ {error &&

{error}

} +
+ ) + case 3: + return ( + +
+
+
+
+ +
+
+

Sync Frequency

+

How often should we pull shopify data?

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

Automated Fulfillment

+

Automatically push new orders to suppliers?

+
+
+ +
+
+ {error &&

{error}

} +
+ ) + case 4: + return ( + +
+ +
+
+

Setup Complete!

+

Your store is now connected. We'll start syncing your products and orders right away.

+
+
+ ) + } + } + + return ( +
+ + +
+
+ {/* Progress Bar */} +
+ {steps.map((step) => ( +
+
= step.id + ? "bg-indigo-500/50 border-indigo-400/30 text-white" + : "bg-white/5 border-white/10 text-slate-500" + }`}> + +
+

= step.id ? "text-indigo-400" : "text-slate-500"}`}>{step.title}

+
+ ))} +
+ +
+
+ +
+ + {renderStep()} + + +
+ + +
+ {currentStep === 2 ? ( + + ) : currentStep === 3 ? ( + + ) : currentStep === 4 ? ( + + ) : ( + + )} +
+
+
+
+
+
+ ) +} diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx new file mode 100644 index 0000000..cbe8779 --- /dev/null +++ b/src/app/onboarding/page.tsx @@ -0,0 +1,23 @@ +import { auth } from "@/auth" +import { prisma } from "@/lib/prisma" +import { redirect } from "next/navigation" +import OnboardingClient from "./onboarding-client" + +export default async function OnboardingPage() { + const session = await auth() + + if (!session?.user) { + redirect("/login") + } + + // Multi-store: redirect if user has at least one store with onboarding complete + const storeWithOnboarding = await prisma.store.findFirst({ + where: { userId: session.user.id, onboardingComplete: true } + }) + + if (storeWithOnboarding) { + redirect("/dashboard") + } + + return +} diff --git a/src/app/onboarding/shopify/page.tsx b/src/app/onboarding/shopify/page.tsx new file mode 100644 index 0000000..eb822d5 --- /dev/null +++ b/src/app/onboarding/shopify/page.tsx @@ -0,0 +1,316 @@ +"use client" + +import { useState } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { + Globe, + Key, + Loader2, + AlertCircle, + Cloud, + CheckCircle2 +} from "lucide-react" +import { useRouter } from "next/navigation" + +export default function ShopifyOnboardingPage() { + const [step, setStep] = useState(1) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [success, setSuccess] = useState(false) + const router = useRouter() + + const [formData, setFormData] = useState({ + businessName: "", + storeName: "", + domain: "", + apiKey: "", + apiSecret: "", + accessToken: "", + }) + + const handleInputChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }) + } + + const validateStep = () => { + if (step === 1) { + if (!formData.businessName || !formData.storeName || !formData.domain) { + setError("Please fill in all store details") + return false + } + } + if (step === 2) { + if (!formData.apiKey || !formData.apiSecret || !formData.accessToken) { + setError("Please provide all Shopify credentials") + return false + } + } + setError("") + return true + } + + const nextStep = () => { + if (validateStep()) setStep(step + 1) + } + + const prevStep = () => { + setStep(step - 1) + } + + const handleSubmit = async () => { + if (!validateStep()) return + setLoading(true) + setError("") + + try { + const res = await fetch("/api/onboarding/shopify", { + method: "POST", + body: JSON.stringify(formData), + headers: { "Content-Type": "application/json" }, + }) + + if (res.ok) { + setSuccess(true) + setTimeout(() => { + router.push("/dashboard") + }, 2000) + } else { + const data = await res.json() + setError(data.message || "Failed to save store credentials") + } + } catch { + setError("An error occurred. Please verify your connection.") + } finally { + setLoading(false) + } + } + + if (success) { + return ( +
+ +
+ +
+

Initialization Successful

+

Redirecting to your dashboard control panel...

+
+
+ ) + } + + return ( +
+ {/* Visual background lines (SVG pattern mock) */} +
+ +
+ {/* Progress Bar */} +
+
+ 01 STORE DETAILS + 02 API CREDENTIALS + 03 SYSTEM SYNC +
+
+ +
+
+ + + {step === 1 && ( + +
+
+ +
+
+

Store Identity

+

Define how your store appears in our network

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ )} + + {step === 2 && ( + +
+
+ +
+
+

Access Protocol

+

Provide your Shopify Admin API credentials

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ )} + + {step === 3 && ( + +
+ + {loading && ( + + )} +
+

Sync Infrastructure

+

+ We are ready to handshake with your Shopify instance. This will finalize your vendor account setup. +

+ + {error && ( +
+ + {error} +
+ )} + +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..b735b22 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,14 @@ +import { auth } from "@/auth" +import { redirect } from "next/navigation" + +export default async function HomePage() { + const session = await auth() + + if (!session) { + redirect("/login") + } else { + redirect("/dashboard") + } + + return null +} diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx new file mode 100644 index 0000000..db4bd11 --- /dev/null +++ b/src/app/privacy-policy/page.tsx @@ -0,0 +1,169 @@ +import Link from "next/link"; + +export default function PrivacyPolicyPage() { + return ( +
+
+
+
+

+ Privacy Policy +

+

+ This page explains how JUNOHUB collects, uses, and protects your + data, including data accessed from connected services such as + Gmail. +

+
+ +
+
+

+ 1. Overview +

+

+ JUNOHUB is a dropshipping and e-commerce management platform. We + collect and process only the data necessary to provide and + improve our services, operate your account, and integrate with + third-party platforms you choose to connect. +

+
+ +
+

+ 2. Data We Collect +

+

+ When you use JUNOHUB, we may collect the following categories of + data: +

+
    +
  • Account information (name, email address, role).
  • +
  • + Store and order data from your connected e-commerce platforms. +
  • +
  • + Configuration and usage data related to your JUNOHUB account. +
  • +
+
+ +
+

+ 3. Gmail and Google Data +

+

+ If you connect your Gmail account, JUNOHUB will request access + to Gmail scopes that allow us to read relevant messages in order + to power JUNOHUB's email and support features. +

+
    +
  • + Gmail data is used solely to provide in-app functionality such + as viewing, classifying, and responding to customer emails. +
  • +
  • + We do not sell or share Gmail content with third parties for + advertising or marketing purposes. +
  • +
  • + Access to Gmail data is restricted to the JUNOHUB systems that + need it to provide the features you enable. +
  • +
+

+ You can revoke JUNOHUB's access to your Google account at + any time via your Google Account security settings. +

+
+ +
+

+ 4. How We Use Your Data +

+

We use your data to:

+
    +
  • Authenticate you and operate your JUNOHUB account.
  • +
  • + Sync and manage products, orders, and inventory with your + connected stores. +
  • +
  • + Provide analytics, notifications, and workflow automation + features. +
  • +
  • + Improve the reliability, security, and performance of JUNOHUB. +
  • +
+
+ +
+

+ 5. Data Storage and Security +

+

+ We store data using industry-standard security practices and + restrict access to authorized personnel and services only. While + no system can be perfectly secure, we continuously work to + protect your information from unauthorized access, disclosure, + alteration, or destruction. +

+
+ +
+

+ 6. Your Rights and Choices +

+

+ Depending on your jurisdiction, you may have rights to: +

+
    +
  • Access the personal data we store about you.
  • +
  • Request correction or deletion of your personal data.
  • +
  • Restrict or object to certain types of processing.
  • +
  • Export your data where technically feasible.
  • +
+
+ +
+

+ 7. Contact +

+

+ If you have questions about this Privacy Policy or our data + practices, please contact us using the support email provided in + your JUNOHUB account or documentation. +

+
+ +
+

+ This Privacy Policy is provided for informational purposes and + may be updated from time to time as JUNOHUB evolves. +

+
+
+ +
+ JUNOHUB Systems © {new Date().getFullYear()} +
+ + Terms of Service + + + Back to Login + +
+
+
+
+
+ ); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..3c8bc41 --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,229 @@ +"use client" + +import { useState } from "react" +import { motion } from "framer-motion" +import { Mail, Lock, Loader2, User, ShoppingBag, Truck, Zap } from "lucide-react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { signIn } from "next-auth/react" + +export default function SignupPage() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [role, setRole] = useState<"VENDOR" | "SUPPLIER">("VENDOR") + const router = useRouter() + + const handleSignup = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError("") + + const formData = new FormData(e.currentTarget) + const name = formData.get("name") as string + const email = formData.get("email") as string + const password = formData.get("password") as string + + try { + const res = await fetch("/api/auth/register", { + method: "POST", + body: JSON.stringify({ name, email, password, role }), + headers: { "Content-Type": "application/json" }, + }) + + if (res.ok) { + router.push("/login?message=Account created successfully") + } else { + const data = await res.json() + setError(data.message || "Something went wrong") + } + } catch { + setError("An error occurred. Please try again.") + } finally { + setLoading(false) + } + } + + const handleGoogleSignIn = () => { + signIn("google", { callbackUrl: "/onboarding" }) + } + + return ( +
+ +
+
+
+
+ +
+

JUNOHUB

+
+

+ Create your Account +

+

+ Join JUNOHUB and streamline your dropshipping business. +

+
+ +
+ + + +
+ +
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + {error && ( +

+ {error} +

+ )} + + +
+ +
+
+
+
+
+ Or continue with +
+
+ + + +

+ Already have an account?{" "} + + Log in here + +

+
+
+
+
+ + Privacy Policy + + • + + Terms of Service + +
+
+
+ ) +} diff --git a/src/app/supplier/layout.tsx b/src/app/supplier/layout.tsx new file mode 100644 index 0000000..2a943d9 --- /dev/null +++ b/src/app/supplier/layout.tsx @@ -0,0 +1,72 @@ +import { SupplierSidebar } from "@/components/SupplierSidebar" +import { MobileSupplierSidebar } from "@/components/MobileSupplierSidebar" +import UserAccountMenu from "@/components/UserAccountMenu" +import NotificationBell from "@/components/NotificationBell" +import PageTransition from "@/components/PageTransition" +import { auth } from "@/auth" +import { redirect } from "next/navigation" + +export default async function SupplierLayout({ + children, +}: { + children: React.ReactNode +}) { + const session = await auth() + + if (!session) { + redirect("/login") + } + + // Only allow SUPPLIER role to access supplier dashboard + if (session.user.role !== "SUPPLIER") { + redirect("/dashboard") + } + + // Ensure supplier profile exists, create if not + const { prisma } = await import("@/lib/prisma") + let supplierProfile = await prisma.supplierProfile.findUnique({ + where: { userId: session.user.id } + }) + + if (!supplierProfile) { + // Create default supplier profile + supplierProfile = await prisma.supplierProfile.create({ + data: { + userId: session.user.id, + companyName: session.user.name || "My Company", + description: null + } + }) + } + + return ( +
+ {/* Desktop Sidebar */} +
+ +
+ +
+
+
+ +

Supplier Portal

+
+
+ +
+ {session.user?.name || "Supplier User"} + {session.user?.email} +
+ +
+
+
+ + {children} + +
+
+
+ ) +} diff --git a/src/app/supplier/orders/page.tsx b/src/app/supplier/orders/page.tsx new file mode 100644 index 0000000..9f0cd27 --- /dev/null +++ b/src/app/supplier/orders/page.tsx @@ -0,0 +1,148 @@ +"use client" + +import { useEffect, useState } from "react" +import { + ShoppingCart, + Search, + Loader2, + AlertCircle, + Package, + CheckCircle2, + Clock, + XCircle +} from "lucide-react" + +export default function SupplierOrdersPage() { + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [searchTerm, setSearchTerm] = useState("") + const [connectedVendors, setConnectedVendors] = useState(0) + + useEffect(() => { + fetchOrders() + }, []) + + const fetchOrders = async () => { + try { + setLoading(true) + const res = await fetch("/api/supplier/orders") + if (!res.ok) throw new Error("Failed to fetch orders") + const data = await res.json() + setOrders(data.orders || []) + setConnectedVendors(data.connectedVendors || 0) + setError("") + } catch { + setError("Failed to load orders") + } finally { + setLoading(false) + } + } + + const filteredOrders = orders.filter((order) => + order.orderNumber?.toLowerCase().includes(searchTerm.toLowerCase()) || + order.vendorName?.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + return ( +
+
+

Orders

+

View and manage orders from vendors

+
+ + {error && ( +
+ + {error} +
+ )} + + {/* Info Card */} +
+
+
+ +
+
+

Order Management

+

+ Track orders placed by your connected vendors. Orders will appear here once vendors place orders for your products. +

+
+
+ + Connected Vendors: {connectedVendors} +
+
+
+
+
+ + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+ + {loading ? ( +
+ +
+ ) : filteredOrders.length === 0 ? ( +
+ +

+ {searchTerm ? "No orders found matching your search" : "No orders yet"} +

+

+ Orders from your connected vendors will appear here once they place orders for your products. +

+
+ ) : ( +
+
+ + + + + + + + + + + + {filteredOrders.map((order) => ( + + + + + + + + ))} + +
Order #VendorDateStatusTotal
{order.orderNumber}{order.vendorName} + {new Date(order.createdAt).toLocaleDateString()} + + + {order.status} + + ${order.total?.toFixed(2)}
+
+
+ )} +
+ ) +} diff --git a/src/app/supplier/page.tsx b/src/app/supplier/page.tsx new file mode 100644 index 0000000..e1c055d --- /dev/null +++ b/src/app/supplier/page.tsx @@ -0,0 +1,244 @@ +"use client" + +import { useEffect, useState } from "react" +import { + Package, + ShoppingCart, + Users, + TrendingUp, + Loader2, + ArrowUpRight, + Clock, + CheckCircle2, + DollarSign, + AlertCircle +} from "lucide-react" +import Link from "next/link" + +export default function SupplierDashboardPage() { + const [stats, setStats] = useState({ + totalProducts: 0, + activeOrders: 0, + connectedVendors: 0, + totalRevenue: 0, + pendingConnections: 0, + recentProducts: 0, + averageProductPrice: 0, + recentConnections: 0 + }) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + + useEffect(() => { + fetchDashboardData() + }, []) + + const fetchDashboardData = async () => { + try { + setLoading(true) + const [profileRes, analyticsRes, ordersRes] = await Promise.all([ + fetch("/api/supplier/profile").catch(() => null), + fetch("/api/supplier/analytics").catch(() => null), + fetch("/api/supplier/orders").catch(() => null) + ]) + + const profileData = profileRes?.ok ? await profileRes.json().catch(() => ({})) : {} + const analyticsData = analyticsRes?.ok ? await analyticsRes.json().catch(() => ({})) : {} + const ordersData = ordersRes?.ok ? await ordersRes.json().catch(() => ({})) : {} + + setStats({ + totalProducts: analyticsData?.stats?.totalProducts || 0, + activeOrders: ordersData?.orders?.length || 0, + connectedVendors: analyticsData?.stats?.connectedVendors || 0, + totalRevenue: 0, + pendingConnections: analyticsData?.stats?.pendingConnections || 0, + recentProducts: analyticsData?.stats?.recentProducts || 0, + averageProductPrice: analyticsData?.stats?.averageProductPrice || 0, + recentConnections: analyticsData?.stats?.recentConnections || 0 + }) + setError("") + } catch { + setError("Failed to load dashboard data") + } finally { + setLoading(false) + } + } + + return ( +
+
+

Supplier Dashboard

+

Welcome to your supplier portal

+
+ + {error && ( +
+ + {error} +
+ )} + + {loading ? ( +
+ +
+ ) : ( + <> + {/* Stats Grid */} +
+
+
+
+ +
+ +
+

{stats.totalProducts}

+

Total Products

+
+ +
+
+
+ +
+ +
+

{stats.activeOrders}

+

Active Orders

+
+ +
+
+
+ +
+ +
+

{stats.connectedVendors}

+

Connected Vendors

+
+ +
+
+
+ +
+ +
+

${stats.totalRevenue.toFixed(2)}

+

Total Revenue

+
+
+ + {/* Additional Stats */} +
+
+
+
+ +
+ +
+

{stats.pendingConnections}

+

Pending Connections

+
+ +
+
+
+ +
+ +
+

${stats.averageProductPrice.toFixed(2)}

+

Avg. Product Price

+
+
+ + {/* Quick Actions & Recent Activity */} +
+
+

Quick Actions

+

Common tasks and shortcuts

+
+ + Add New Product + + + View Vendor Connections + + + View Orders + +
+
+ +
+

Recent Activity (7 days)

+
+ {stats.pendingConnections > 0 && ( +
+ +
+

{stats.pendingConnections} pending connection{stats.pendingConnections !== 1 ? "s" : ""}

+ + Review now → + +
+
+ )} + {stats.recentProducts > 0 && ( +
+ +
+

{stats.recentProducts} new product{stats.recentProducts !== 1 ? "s" : ""} added

+ + View products → + +
+
+ )} + {stats.recentConnections > 0 && ( +
+ +
+

{stats.recentConnections} new connection{stats.recentConnections !== 1 ? "s" : ""}

+ + View vendors → + +
+
+ )} + {stats.pendingConnections === 0 && stats.recentProducts === 0 && stats.recentConnections === 0 && ( +

No recent activity

+ )} +
+
+
+ + {/* Performance Overview */} +
+

Performance Overview

+

+ Track your supplier performance metrics and growth over time. +

+
+ +

Detailed charts coming soon

+
+
+ + )} +
+ ) +} diff --git a/src/app/supplier/products/page.tsx b/src/app/supplier/products/page.tsx new file mode 100644 index 0000000..3e63b39 --- /dev/null +++ b/src/app/supplier/products/page.tsx @@ -0,0 +1,420 @@ +"use client" + +import { useEffect, useState, useRef } from "react" +import { + Package, + Plus, + Search, + Edit, + Trash2, + Loader2, + AlertCircle, + Image as ImageIcon, + X, + CheckCircle2, + Building2 +} from "lucide-react" +import Image from "next/image" + +interface Product { + id: string + title: string + description: string | null + price: number + sku: string | null + imageUrl: string | null + createdAt: string + updatedAt: string + isSynced?: boolean // Flag for synced products from vendors + syncId?: string // ID of the sync record + shopifyProductId?: string // Shopify product ID for synced products + vendor?: { + id: string + businessName: string + } +} + +export default function SupplierProductsPage() { + const [products, setProducts] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [searchTerm, setSearchTerm] = useState("") + const [showModal, setShowModal] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [formData, setFormData] = useState({ + title: "", + description: "", + price: "", + sku: "", + imageUrl: "" + }) + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + fetchProducts() + }, []) + + useEffect(() => { + if (success) { + const timer = setTimeout(() => setSuccess(""), 5000) + return () => clearTimeout(timer) + } + }, [success]) + + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(""), 5000) + return () => clearTimeout(timer) + } + }, [error]) + + const fetchProducts = async () => { + try { + setLoading(true) + const res = await fetch("/api/supplier/products") + if (!res.ok) throw new Error("Failed to fetch products") + const data = await res.json() + setProducts(data.products || []) + setError("") + } catch { + setError("Failed to load products") + } finally { + setLoading(false) + } + } + + const handleAdd = () => { + setEditingProduct(null) + setFormData({ + title: "", + description: "", + price: "", + sku: "", + imageUrl: "" + }) + setShowModal(true) + } + + const handleEdit = (product: Product) => { + setEditingProduct(product) + setFormData({ + title: product.title, + description: product.description || "", + price: product.price.toString(), + sku: product.sku || "", + imageUrl: product.imageUrl || "" + }) + setShowModal(true) + } + + const handleDelete = async (productId: string) => { + // Check if this is a synced product + const product = products.find(p => p.id === productId) + if (product?.isSynced) { + setError("Synced products from vendors cannot be deleted. You can only edit them.") + return + } + + if (!confirm("Are you sure you want to delete this product?")) return + + try { + const res = await fetch(`/api/supplier/products/${productId}`, { + method: "DELETE" + }) + if (!res.ok) { + const data = await res.json() + throw new Error(data.message || "Failed to delete product") + } + setSuccess("Product deleted successfully") + fetchProducts() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete product") + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setSubmitting(true) + setError("") + + try { + const url = editingProduct + ? `/api/supplier/products/${editingProduct.id}` + : "/api/supplier/products" + const method = editingProduct ? "PUT" : "POST" + + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: formData.title, + description: formData.description || null, + price: parseFloat(formData.price), + sku: formData.sku || null, + imageUrl: formData.imageUrl || null + }) + }) + + if (!res.ok) { + const data = await res.json() + throw new Error(data.message || "Failed to save product") + } + + setSuccess(editingProduct ? "Product updated successfully" : "Product created successfully") + setShowModal(false) + fetchProducts() + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save product") + } finally { + setSubmitting(false) + } + } + + const filteredProducts = products.filter((product) => + product.title.toLowerCase().includes(searchTerm.toLowerCase()) || + product.sku?.toLowerCase().includes(searchTerm.toLowerCase()) + ) + + return ( +
+
+
+

Products

+

Manage your product catalog

+
+ +
+ + {success && ( +
+ + {success} +
+ )} + + {error && ( +
+ + {error} +
+ )} + + {/* Search */} +
+ + setSearchTerm(e.target.value)} + className="w-full h-12 bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + /> +
+ + {loading ? ( +
+ +
+ ) : filteredProducts.length === 0 ? ( +
+ +

+ {searchTerm ? "No products found matching your search" : "No products yet. Add your first product to get started."} +

+
+ ) : ( +
+
+ + + + + + + + + + + {filteredProducts.map((product) => ( + + + + + + + ))} + +
ProductSKUPriceActions
+
+ {product.imageUrl ? ( + {product.title} + ) : ( +
+ +
+ )} +
+
+
{product.title}
+ {product.isSynced && ( + + Synced + + )} +
+ {product.description && ( +
{product.description.replace(/<[^>]*>/g, "")}
+ )} + {product.isSynced && product.vendor && ( +
+ + From {product.vendor.businessName} +
+ )} +
+
+
{product.sku || "—"}${product.price.toFixed(2)} +
+ + {!product.isSynced && ( + + )} +
+
+
+
+ )} + + {/* Add/Edit Modal */} + {showModal && ( +
+
+
+

+ {editingProduct ? "Edit Product" : "Add Product"} +

+ +
+ +
+
+ + setFormData({ ...formData, title: e.target.value })} + className="w-full px-4 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30 focus:border-indigo-500/50" + placeholder="Product title" + /> +
+ +
+ +