Merge pull request #11 from vespo92/feature/complete-integration
Some checks failed
CI / Type Check (push) Failing after 40s
CI / Lint & Format (push) Failing after 6m39s
CI / Security Scan (push) Has been skipped
CI / Unit & Integration Tests (push) Failing after 6m57s
CI / Build (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Docker Build (push) Failing after 1m24s
Some checks failed
CI / Type Check (push) Failing after 40s
CI / Lint & Format (push) Failing after 6m39s
CI / Security Scan (push) Has been skipped
CI / Unit & Integration Tests (push) Failing after 6m57s
CI / Build (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CI / Docker Build (push) Failing after 1m24s
Complete Integration: Merge All 15 Agent Branches
This commit is contained in:
commit
baca320262
253 changed files with 47524 additions and 93 deletions
77
.dockerignore
Normal file
77
.dockerignore
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# LocalGreenChain Docker Ignore
|
||||
# Prevents copying unnecessary files to Docker context
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
__tests__
|
||||
|
||||
# Build outputs (we rebuild inside container)
|
||||
.next
|
||||
out
|
||||
build
|
||||
dist
|
||||
|
||||
# Development files
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Environment files (should be passed at runtime)
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.docker
|
||||
|
||||
# Documentation (not needed in production)
|
||||
*.md
|
||||
docs
|
||||
CHANGELOG.md
|
||||
README.md
|
||||
LICENSE
|
||||
|
||||
# Misc
|
||||
.eslintcache
|
||||
.turbo
|
||||
*.tsbuildinfo
|
||||
|
||||
# Data files (should be mounted as volumes)
|
||||
data
|
||||
*.json.bak
|
||||
|
||||
# Tor configuration (handled separately)
|
||||
tor
|
||||
|
||||
# Infrastructure files
|
||||
infra
|
||||
.github
|
||||
107
.env.example
107
.env.example
|
|
@ -1,25 +1,124 @@
|
|||
# =============================================================================
|
||||
# LocalGreenChain Environment Variables
|
||||
# Agent 4: Production Deployment
|
||||
# Copy this file to .env.local and fill in the values
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Application Settings
|
||||
# -----------------------------------------------------------------------------
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
NEXT_PUBLIC_APP_NAME=LocalGreenChain
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (PostgreSQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL=postgresql://lgc:lgc_password@localhost:5432/localgreenchain
|
||||
DB_USER=lgc
|
||||
DB_PASSWORD=lgc_password
|
||||
DB_NAME=localgreenchain
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Redis Cache
|
||||
# -----------------------------------------------------------------------------
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Authentication (NextAuth.js)
|
||||
# Generate secret: openssl rand -base64 32
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXTAUTH_URL=http://localhost:3001
|
||||
NEXTAUTH_SECRET=your-secret-key-change-in-production
|
||||
|
||||
# OAuth Providers (optional)
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Error Tracking (Sentry)
|
||||
# -----------------------------------------------------------------------------
|
||||
SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
SENTRY_ORG=
|
||||
SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
# Levels: error, warn, info, debug, trace
|
||||
# -----------------------------------------------------------------------------
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=json
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Monitoring
|
||||
# -----------------------------------------------------------------------------
|
||||
PROMETHEUS_ENABLED=false
|
||||
METRICS_PORT=9091
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Plants.net API (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
PLANTS_NET_API_KEY=your_api_key_here
|
||||
|
||||
# Tor Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tor Configuration (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
TOR_ENABLED=false
|
||||
TOR_SOCKS_HOST=127.0.0.1
|
||||
TOR_SOCKS_PORT=9050
|
||||
TOR_CONTROL_PORT=9051
|
||||
TOR_HIDDEN_SERVICE_DIR=/var/lib/tor/localgreenchain
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Privacy Settings
|
||||
# -----------------------------------------------------------------------------
|
||||
DEFAULT_PRIVACY_MODE=standard
|
||||
ALLOW_ANONYMOUS_REGISTRATION=true
|
||||
LOCATION_OBFUSCATION_DEFAULT=fuzzy
|
||||
|
||||
# Application Settings
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
# -----------------------------------------------------------------------------
|
||||
# File Storage (S3/R2/MinIO)
|
||||
# -----------------------------------------------------------------------------
|
||||
STORAGE_PROVIDER=local
|
||||
S3_BUCKET=
|
||||
S3_REGION=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_ENDPOINT=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email (SMTP)
|
||||
# -----------------------------------------------------------------------------
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@localgreenchain.local
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Rate Limiting
|
||||
# -----------------------------------------------------------------------------
|
||||
RATE_LIMIT_WINDOW_MS=60000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Security
|
||||
# -----------------------------------------------------------------------------
|
||||
CORS_ORIGINS=http://localhost:3001
|
||||
CSP_REPORT_URI=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy Drupal Settings (for backward compatibility)
|
||||
# -----------------------------------------------------------------------------
|
||||
NEXT_PUBLIC_DRUPAL_BASE_URL=http://localhost:8080
|
||||
NEXT_IMAGE_DOMAIN=localhost
|
||||
DRUPAL_CLIENT_ID=52ce1a10-bf5c-4c81-8edf-eea3af95da84
|
||||
|
|
|
|||
236
.github/workflows/ci.yml
vendored
Normal file
236
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
# LocalGreenChain CI Pipeline
|
||||
# Combined: Agent 4 (Production Deployment) + Agent 5 (Testing)
|
||||
#
|
||||
# Runs on every push and pull request:
|
||||
# - Linting, formatting, and type checking
|
||||
# - Unit and integration tests
|
||||
# - E2E tests with Cypress
|
||||
# - Build verification
|
||||
# - Docker build (main branch only)
|
||||
# - Security scanning
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_ENV: test
|
||||
NODE_VERSION: '18'
|
||||
|
||||
jobs:
|
||||
# ==========================================================================
|
||||
# Lint and Type Check
|
||||
# ==========================================================================
|
||||
lint:
|
||||
name: Lint & Format
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run ESLint
|
||||
run: bun run lint
|
||||
|
||||
- name: Check formatting
|
||||
run: bun run format:check
|
||||
|
||||
type-check:
|
||||
name: Type Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: bun run type-check
|
||||
|
||||
# ==========================================================================
|
||||
# Unit Tests
|
||||
# ==========================================================================
|
||||
test:
|
||||
name: Unit & Integration Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: bun run test:ci
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
coverage/
|
||||
junit.xml
|
||||
retention-days: 30
|
||||
|
||||
# ==========================================================================
|
||||
# Build
|
||||
# ==========================================================================
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [lint, type-check, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build application
|
||||
run: bun run build
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: .next/
|
||||
retention-days: 7
|
||||
|
||||
# ==========================================================================
|
||||
# E2E Tests
|
||||
# ==========================================================================
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: build-output
|
||||
path: .next/
|
||||
|
||||
- name: Run Cypress tests
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
start: bun run start
|
||||
wait-on: 'http://localhost:3001'
|
||||
wait-on-timeout: 120
|
||||
browser: chrome
|
||||
record: false
|
||||
|
||||
- name: Upload Cypress screenshots
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-screenshots
|
||||
path: cypress/screenshots
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Cypress videos
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: cypress-videos
|
||||
path: cypress/videos
|
||||
retention-days: 7
|
||||
|
||||
# ==========================================================================
|
||||
# Docker Build (only on main branch)
|
||||
# ==========================================================================
|
||||
docker:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [build]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: localgreenchain:${{ github.sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ==========================================================================
|
||||
# Security Scan
|
||||
# ==========================================================================
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Run security audit
|
||||
run: bun pm audit || true
|
||||
continue-on-error: true
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
scan-type: 'fs'
|
||||
scan-ref: '.'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
exit-code: '0'
|
||||
169
.github/workflows/deploy.yml
vendored
Normal file
169
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# LocalGreenChain Production Deployment
|
||||
# Agent 4: Production Deployment
|
||||
#
|
||||
# Deploys to production when a release is published
|
||||
# or manually triggered
|
||||
|
||||
name: Deploy Production
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.event.inputs.environment || 'production' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# ==========================================================================
|
||||
# Build and Push Docker Image
|
||||
# ==========================================================================
|
||||
build:
|
||||
name: Build & Push Image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
outputs:
|
||||
image_tag: ${{ steps.meta.outputs.tags }}
|
||||
image_digest: ${{ steps.build.outputs.digest }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
NEXT_PUBLIC_API_URL=${{ vars.API_URL }}
|
||||
NEXT_PUBLIC_SENTRY_DSN=${{ vars.SENTRY_DSN }}
|
||||
|
||||
# ==========================================================================
|
||||
# Deploy to Production
|
||||
# ==========================================================================
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: [build]
|
||||
environment:
|
||||
name: ${{ github.event.inputs.environment || 'production' }}
|
||||
url: ${{ vars.APP_URL }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy notification (start)
|
||||
run: |
|
||||
echo "🚀 Starting deployment to ${{ github.event.inputs.environment || 'production' }}"
|
||||
echo "Image: ${{ needs.build.outputs.image_tag }}"
|
||||
|
||||
# Add your deployment steps here
|
||||
# Examples:
|
||||
# - SSH and docker-compose pull/up
|
||||
# - Kubernetes deployment
|
||||
# - Cloud provider specific deployment
|
||||
|
||||
- name: Deploy notification (complete)
|
||||
run: |
|
||||
echo "✅ Deployment completed successfully"
|
||||
|
||||
# ==========================================================================
|
||||
# Post-Deployment Verification
|
||||
# ==========================================================================
|
||||
verify:
|
||||
name: Verify Deployment
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [deploy]
|
||||
|
||||
steps:
|
||||
- name: Wait for deployment to stabilize
|
||||
run: sleep 30
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
for i in {1..5}; do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" ${{ vars.APP_URL }}/api/health || echo "000")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "✅ Health check passed"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i: Status $status, retrying..."
|
||||
sleep 10
|
||||
done
|
||||
echo "❌ Health check failed after 5 attempts"
|
||||
exit 1
|
||||
|
||||
- name: Smoke tests
|
||||
run: |
|
||||
# Verify critical endpoints
|
||||
curl -f ${{ vars.APP_URL }}/api/health/live || exit 1
|
||||
curl -f ${{ vars.APP_URL }}/api/health/ready || exit 1
|
||||
echo "✅ Smoke tests passed"
|
||||
|
||||
# ==========================================================================
|
||||
# Rollback on Failure
|
||||
# ==========================================================================
|
||||
rollback:
|
||||
name: Rollback
|
||||
runs-on: ubuntu-latest
|
||||
needs: [verify]
|
||||
if: failure()
|
||||
|
||||
steps:
|
||||
- name: Rollback notification
|
||||
run: |
|
||||
echo "⚠️ Deployment verification failed, initiating rollback..."
|
||||
# Add rollback logic here
|
||||
|
||||
- name: Alert team
|
||||
run: |
|
||||
echo "🔔 Deployment failed - team has been notified"
|
||||
139
.github/workflows/preview.yml
vendored
Normal file
139
.github/workflows/preview.yml
vendored
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# LocalGreenChain Preview Deployments
|
||||
# Agent 4: Production Deployment
|
||||
#
|
||||
# Creates preview deployments for pull requests
|
||||
|
||||
name: Preview Deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: preview-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ==========================================================================
|
||||
# Build Preview
|
||||
# ==========================================================================
|
||||
build:
|
||||
name: Build Preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Build application
|
||||
run: bun run build
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_API_URL: https://preview-${{ github.event.pull_request.number }}.localgreenchain.dev
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: preview-build
|
||||
path: |
|
||||
.next/
|
||||
public/
|
||||
package.json
|
||||
next.config.js
|
||||
retention-days: 7
|
||||
|
||||
# ==========================================================================
|
||||
# Deploy Preview
|
||||
# ==========================================================================
|
||||
deploy:
|
||||
name: Deploy Preview
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: [build]
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: preview-build
|
||||
|
||||
- name: Deploy preview
|
||||
id: deploy
|
||||
run: |
|
||||
# Add your preview deployment logic here
|
||||
# Examples: Vercel, Netlify, or custom solution
|
||||
|
||||
PREVIEW_URL="https://preview-${{ github.event.pull_request.number }}.localgreenchain.dev"
|
||||
echo "preview_url=${PREVIEW_URL}" >> $GITHUB_OUTPUT
|
||||
echo "Deployed to: ${PREVIEW_URL}"
|
||||
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const previewUrl = '${{ steps.deploy.outputs.preview_url }}';
|
||||
|
||||
// Find existing comment
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('Preview Deployment')
|
||||
);
|
||||
|
||||
const body = `## 🚀 Preview Deployment
|
||||
|
||||
| Status | URL |
|
||||
|--------|-----|
|
||||
| ✅ Ready | [${previewUrl}](${previewUrl}) |
|
||||
|
||||
**Commit:** \`${context.sha.substring(0, 7)}\`
|
||||
**Updated:** ${new Date().toISOString()}
|
||||
|
||||
---
|
||||
<sub>This preview will be automatically deleted when the PR is closed.</sub>`;
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: body
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# Cleanup on PR Close
|
||||
# ==========================================================================
|
||||
cleanup:
|
||||
name: Cleanup Preview
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.action == 'closed'
|
||||
|
||||
steps:
|
||||
- name: Delete preview deployment
|
||||
run: |
|
||||
echo "Cleaning up preview deployment for PR #${{ github.event.pull_request.number }}"
|
||||
# Add your cleanup logic here
|
||||
36
.husky/_/husky.sh
Executable file
36
.husky/_/husky.sh
Executable file
|
|
@ -0,0 +1,36 @@
|
|||
#!/usr/bin/env sh
|
||||
if [ -z "$husky_skip_init" ]; then
|
||||
debug () {
|
||||
if [ "$HUSKY_DEBUG" = "1" ]; then
|
||||
echo "husky (debug) - $1"
|
||||
fi
|
||||
}
|
||||
|
||||
readonly hook_name="$(basename -- "$0")"
|
||||
debug "starting $hook_name..."
|
||||
|
||||
if [ "$HUSKY" = "0" ]; then
|
||||
debug "HUSKY env variable is set to 0, skipping hook"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f ~/.huskyrc ]; then
|
||||
debug "sourcing ~/.huskyrc"
|
||||
. ~/.huskyrc
|
||||
fi
|
||||
|
||||
readonly husky_skip_init=1
|
||||
export husky_skip_init
|
||||
sh -e "$0" "$@"
|
||||
exitCode="$?"
|
||||
|
||||
if [ $exitCode != 0 ]; then
|
||||
echo "husky - $hook_name hook exited with code $exitCode (error)"
|
||||
fi
|
||||
|
||||
if [ $exitCode = 127 ]; then
|
||||
echo "husky - command not found in PATH=$PATH"
|
||||
fi
|
||||
|
||||
exit $exitCode
|
||||
fi
|
||||
4
.husky/commit-msg
Executable file
4
.husky/commit-msg
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit ${1}
|
||||
4
.husky/pre-commit
Executable file
4
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
10
.prettierignore
Normal file
10
.prettierignore
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
coverage/
|
||||
.git/
|
||||
*.min.js
|
||||
*.min.css
|
||||
bun.lockb
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
76
Dockerfile
76
Dockerfile
|
|
@ -1,40 +1,82 @@
|
|||
# Dockerfile for LocalGreenChain
|
||||
# Uses Bun for fast builds and runtime
|
||||
# Multi-stage production build with Bun runtime
|
||||
# Agent 4: Production Deployment
|
||||
|
||||
FROM oven/bun:1 as base
|
||||
# =============================================================================
|
||||
# Stage 1: Dependencies
|
||||
# =============================================================================
|
||||
FROM oven/bun:1 AS deps
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies only (better caching)
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN bun install --frozen-lockfile --production=false
|
||||
|
||||
# Copy application code
|
||||
# =============================================================================
|
||||
# Stage 2: Builder
|
||||
# =============================================================================
|
||||
FROM oven/bun:1 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
|
||||
|
||||
# Disable Next.js telemetry during build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build Next.js application
|
||||
RUN bun run build
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1-slim as production
|
||||
# Remove development dependencies
|
||||
RUN bun install --frozen-lockfile --production
|
||||
|
||||
# =============================================================================
|
||||
# Stage 3: Production Runner
|
||||
# =============================================================================
|
||||
FROM oven/bun:1-slim AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies and build output
|
||||
COPY --from=base /app/node_modules ./node_modules
|
||||
COPY --from=base /app/.next ./.next
|
||||
COPY --from=base /app/public ./public
|
||||
COPY --from=base /app/package.json ./package.json
|
||||
COPY --from=base /app/next.config.js ./next.config.js
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
# Copy necessary files from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
|
||||
# Copy Next.js build output with proper ownership
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# Create data directory with proper permissions
|
||||
RUN mkdir -p /app/data && chown -R nextjs:nodejs /app/data
|
||||
|
||||
# Set production environment
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3001
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Set environment to production
|
||||
ENV NODE_ENV=production
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3001/api/health || exit 1
|
||||
|
||||
# Switch to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Run the application
|
||||
CMD ["bun", "run", "start"]
|
||||
|
|
|
|||
259
__tests__/api/agents/lineage.test.ts
Normal file
259
__tests__/api/agents/lineage.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* PlantLineageAgent API Tests
|
||||
* Tests for lineage agent API endpoints
|
||||
*/
|
||||
|
||||
import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents';
|
||||
import { getBlockchain, initializeBlockchain } from '../../../lib/blockchain/manager';
|
||||
|
||||
describe('PlantLineageAgent API', () => {
|
||||
let agent: PlantLineageAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get fresh agent instance
|
||||
agent = getPlantLineageAgent();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Ensure agent is stopped after each test
|
||||
if (agent.status === 'running') {
|
||||
await agent.stop();
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/agents/lineage', () => {
|
||||
it('should return agent status and configuration', () => {
|
||||
expect(agent.config.id).toBe('plant-lineage-agent');
|
||||
expect(agent.config.name).toBe('Plant Lineage Agent');
|
||||
expect(agent.config.description).toBeDefined();
|
||||
expect(agent.config.priority).toBe('high');
|
||||
expect(agent.config.intervalMs).toBe(60000);
|
||||
});
|
||||
|
||||
it('should return current metrics', () => {
|
||||
const metrics = agent.getMetrics();
|
||||
|
||||
expect(metrics.agentId).toBe('plant-lineage-agent');
|
||||
expect(metrics.tasksCompleted).toBeGreaterThanOrEqual(0);
|
||||
expect(metrics.tasksFailed).toBeGreaterThanOrEqual(0);
|
||||
expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return network statistics', () => {
|
||||
const networkStats = agent.getNetworkStats();
|
||||
|
||||
expect(networkStats.totalPlants).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.totalLineages).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.avgGenerationDepth).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.avgLineageSize).toBeGreaterThanOrEqual(0);
|
||||
expect(networkStats.geographicSpread).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should return anomaly summary', () => {
|
||||
const anomalies = agent.getAnomalies();
|
||||
|
||||
expect(Array.isArray(anomalies)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/agents/lineage', () => {
|
||||
it('should start agent successfully', async () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
|
||||
await agent.start();
|
||||
|
||||
expect(agent.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should stop agent successfully', async () => {
|
||||
await agent.start();
|
||||
expect(agent.status).toBe('running');
|
||||
|
||||
await agent.stop();
|
||||
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should pause and resume agent', async () => {
|
||||
await agent.start();
|
||||
expect(agent.status).toBe('running');
|
||||
|
||||
agent.pause();
|
||||
expect(agent.status).toBe('paused');
|
||||
|
||||
agent.resume();
|
||||
expect(agent.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should handle start when already running', async () => {
|
||||
await agent.start();
|
||||
const firstStatus = agent.status;
|
||||
|
||||
await agent.start(); // Should not throw
|
||||
|
||||
expect(agent.status).toBe(firstStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/agents/lineage/[plantId]', () => {
|
||||
it('should return null for non-existent plant analysis', () => {
|
||||
const analysis = agent.getLineageAnalysis('non-existent-plant-id');
|
||||
|
||||
expect(analysis).toBeNull();
|
||||
});
|
||||
|
||||
it('should return analysis structure when available', () => {
|
||||
// Analysis would be populated after agent runs
|
||||
// For now, test the structure expectations
|
||||
const analysis = agent.getLineageAnalysis('test-plant-id');
|
||||
|
||||
// Should return null for non-cached plant
|
||||
expect(analysis).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/agents/lineage/anomalies', () => {
|
||||
it('should return empty array when no anomalies', () => {
|
||||
const anomalies = agent.getAnomalies();
|
||||
|
||||
expect(Array.isArray(anomalies)).toBe(true);
|
||||
});
|
||||
|
||||
it('should support filtering by severity', () => {
|
||||
const allAnomalies = agent.getAnomalies();
|
||||
|
||||
const highSeverity = allAnomalies.filter(a => a.severity === 'high');
|
||||
const mediumSeverity = allAnomalies.filter(a => a.severity === 'medium');
|
||||
const lowSeverity = allAnomalies.filter(a => a.severity === 'low');
|
||||
|
||||
expect(highSeverity.length + mediumSeverity.length + lowSeverity.length).toBe(allAnomalies.length);
|
||||
});
|
||||
|
||||
it('should support filtering by type', () => {
|
||||
const allAnomalies = agent.getAnomalies();
|
||||
const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location'];
|
||||
|
||||
for (const anomaly of allAnomalies) {
|
||||
expect(validTypes).toContain(anomaly.type);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Lifecycle', () => {
|
||||
it('should track uptime correctly', async () => {
|
||||
const initialMetrics = agent.getMetrics();
|
||||
const initialUptime = initialMetrics.uptime;
|
||||
|
||||
await agent.start();
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const runningMetrics = agent.getMetrics();
|
||||
expect(runningMetrics.uptime).toBeGreaterThan(initialUptime);
|
||||
});
|
||||
|
||||
it('should maintain metrics across start/stop cycles', async () => {
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.uptime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alert Management', () => {
|
||||
it('should return alerts array', () => {
|
||||
const alerts = agent.getAlerts();
|
||||
|
||||
expect(Array.isArray(alerts)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have proper alert structure', () => {
|
||||
const alerts = agent.getAlerts();
|
||||
|
||||
for (const alert of alerts) {
|
||||
expect(alert.id).toBeDefined();
|
||||
expect(alert.agentId).toBe('plant-lineage-agent');
|
||||
expect(alert.severity).toBeDefined();
|
||||
expect(alert.title).toBeDefined();
|
||||
expect(alert.message).toBeDefined();
|
||||
expect(alert.timestamp).toBeDefined();
|
||||
expect(typeof alert.acknowledged).toBe('boolean');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle agent operations gracefully', async () => {
|
||||
// Test that agent doesn't throw on basic operations
|
||||
expect(() => agent.getMetrics()).not.toThrow();
|
||||
expect(() => agent.getAnomalies()).not.toThrow();
|
||||
expect(() => agent.getNetworkStats()).not.toThrow();
|
||||
expect(() => agent.getAlerts()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle pause on non-running agent', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
|
||||
agent.pause(); // Should not throw
|
||||
|
||||
// Status should remain idle (only pauses if running)
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should handle resume on non-paused agent', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
|
||||
agent.resume(); // Should not throw
|
||||
|
||||
// Status should remain idle (only resumes if paused)
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Format', () => {
|
||||
it('should return consistent metrics format', () => {
|
||||
const metrics = agent.getMetrics();
|
||||
|
||||
expect(typeof metrics.agentId).toBe('string');
|
||||
expect(typeof metrics.tasksCompleted).toBe('number');
|
||||
expect(typeof metrics.tasksFailed).toBe('number');
|
||||
expect(typeof metrics.averageExecutionMs).toBe('number');
|
||||
expect(typeof metrics.uptime).toBe('number');
|
||||
expect(Array.isArray(metrics.errors)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return consistent network stats format', () => {
|
||||
const stats = agent.getNetworkStats();
|
||||
|
||||
expect(typeof stats.totalPlants).toBe('number');
|
||||
expect(typeof stats.totalLineages).toBe('number');
|
||||
expect(typeof stats.avgGenerationDepth).toBe('number');
|
||||
expect(typeof stats.avgLineageSize).toBe('number');
|
||||
expect(typeof stats.geographicSpread).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlantLineageAgent Integration', () => {
|
||||
it('should be accessible via singleton', () => {
|
||||
const agent1 = getPlantLineageAgent();
|
||||
const agent2 = getPlantLineageAgent();
|
||||
|
||||
expect(agent1).toBe(agent2);
|
||||
});
|
||||
|
||||
it('should have correct priority', () => {
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
expect(agent.config.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('should have correct interval', () => {
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
// Should run every minute
|
||||
expect(agent.config.intervalMs).toBe(60000);
|
||||
});
|
||||
});
|
||||
180
__tests__/api/plants.test.ts
Normal file
180
__tests__/api/plants.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Plants API Tests
|
||||
* Integration tests for plant-related API endpoints
|
||||
*/
|
||||
|
||||
// Mock the blockchain manager
|
||||
jest.mock('../../lib/blockchain/manager', () => ({
|
||||
getBlockchain: jest.fn(() => ({
|
||||
getChain: jest.fn(() => [
|
||||
// Genesis block
|
||||
{
|
||||
index: 0,
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
plant: { id: 'genesis' },
|
||||
previousHash: '0',
|
||||
hash: 'genesis-hash',
|
||||
nonce: 0,
|
||||
},
|
||||
// Test plants
|
||||
{
|
||||
index: 1,
|
||||
timestamp: '2024-01-02T00:00:00Z',
|
||||
plant: {
|
||||
id: 'plant-1',
|
||||
name: 'Cherry Tomato',
|
||||
species: 'Tomato',
|
||||
variety: 'Cherry',
|
||||
generation: 1,
|
||||
propagationType: 'original',
|
||||
status: 'healthy',
|
||||
location: { latitude: 40.7128, longitude: -74.006 },
|
||||
},
|
||||
previousHash: 'genesis-hash',
|
||||
hash: 'hash-1',
|
||||
nonce: 1,
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
timestamp: '2024-01-03T00:00:00Z',
|
||||
plant: {
|
||||
id: 'plant-2',
|
||||
name: 'Sweet Basil',
|
||||
species: 'Basil',
|
||||
variety: 'Genovese',
|
||||
generation: 1,
|
||||
propagationType: 'seed',
|
||||
parentPlantId: 'plant-1',
|
||||
status: 'thriving',
|
||||
location: { latitude: 40.7228, longitude: -74.016 },
|
||||
},
|
||||
previousHash: 'hash-1',
|
||||
hash: 'hash-2',
|
||||
nonce: 2,
|
||||
},
|
||||
]),
|
||||
addPlant: jest.fn((plant) => ({
|
||||
index: 3,
|
||||
timestamp: new Date().toISOString(),
|
||||
plant,
|
||||
previousHash: 'hash-2',
|
||||
hash: 'hash-3',
|
||||
nonce: 3,
|
||||
})),
|
||||
findPlant: jest.fn((id) => {
|
||||
if (id === 'plant-1') {
|
||||
return {
|
||||
index: 1,
|
||||
plant: {
|
||||
id: 'plant-1',
|
||||
name: 'Cherry Tomato',
|
||||
species: 'Tomato',
|
||||
variety: 'Cherry',
|
||||
generation: 1,
|
||||
status: 'healthy',
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
isValid: jest.fn(() => true),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Plants API', () => {
|
||||
describe('GET /api/plants', () => {
|
||||
it('should return plant list', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
|
||||
expect(chain.length).toBeGreaterThan(1);
|
||||
expect(chain[1].plant.name).toBe('Cherry Tomato');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/plants/[id]', () => {
|
||||
it('should return plant by ID', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
const plant = blockchain.findPlant('plant-1');
|
||||
|
||||
expect(plant).toBeDefined();
|
||||
expect(plant.plant.name).toBe('Cherry Tomato');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent plant', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
const plant = blockchain.findPlant('non-existent');
|
||||
|
||||
expect(plant).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/plants/register', () => {
|
||||
it('should register new plant', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
const newPlant = {
|
||||
id: 'plant-3',
|
||||
name: 'New Plant',
|
||||
species: 'Test',
|
||||
variety: 'Test',
|
||||
generation: 1,
|
||||
propagationType: 'seed',
|
||||
status: 'healthy',
|
||||
location: { latitude: 40.7, longitude: -74.0 },
|
||||
};
|
||||
|
||||
const block = blockchain.addPlant(newPlant);
|
||||
|
||||
expect(block).toBeDefined();
|
||||
expect(block.plant.name).toBe('New Plant');
|
||||
expect(blockchain.addPlant).toHaveBeenCalledWith(newPlant);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/plants/network', () => {
|
||||
it('should return network statistics', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
|
||||
// Calculate network stats
|
||||
const plants = chain.slice(1); // Skip genesis
|
||||
const totalPlants = plants.length;
|
||||
const speciesCounts: Record<string, number> = {};
|
||||
|
||||
plants.forEach((block: any) => {
|
||||
const species = block.plant.species;
|
||||
speciesCounts[species] = (speciesCounts[species] || 0) + 1;
|
||||
});
|
||||
|
||||
expect(totalPlants).toBe(2);
|
||||
expect(speciesCounts['Tomato']).toBe(1);
|
||||
expect(speciesCounts['Basil']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/plants/lineage/[id]', () => {
|
||||
it('should return lineage for plant with parent', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
|
||||
const plant2 = chain[2].plant;
|
||||
expect(plant2.parentPlantId).toBe('plant-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Blockchain Validation', () => {
|
||||
it('should validate chain integrity', async () => {
|
||||
const { getBlockchain } = require('../../lib/blockchain/manager');
|
||||
const blockchain = getBlockchain();
|
||||
|
||||
expect(blockchain.isValid()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
215
__tests__/lib/agents/GrowerAdvisoryAgent.test.ts
Normal file
215
__tests__/lib/agents/GrowerAdvisoryAgent.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* GrowerAdvisoryAgent Tests
|
||||
* Tests for the grower advisory and recommendation system
|
||||
*/
|
||||
|
||||
import {
|
||||
GrowerAdvisoryAgent,
|
||||
getGrowerAdvisoryAgent,
|
||||
} from '../../../lib/agents/GrowerAdvisoryAgent';
|
||||
|
||||
describe('GrowerAdvisoryAgent', () => {
|
||||
let agent: GrowerAdvisoryAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
agent = new GrowerAdvisoryAgent();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create agent with correct configuration', () => {
|
||||
expect(agent.config.id).toBe('grower-advisory-agent');
|
||||
expect(agent.config.name).toBe('Grower Advisory Agent');
|
||||
expect(agent.config.enabled).toBe(true);
|
||||
expect(agent.config.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('should have correct interval (5 minutes)', () => {
|
||||
expect(agent.config.intervalMs).toBe(300000);
|
||||
});
|
||||
|
||||
it('should start in idle status', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should have empty metrics initially', () => {
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.tasksCompleted).toBe(0);
|
||||
expect(metrics.tasksFailed).toBe(0);
|
||||
expect(metrics.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grower Profile Management', () => {
|
||||
it('should register a grower profile', () => {
|
||||
const profile = createGrowerProfile('grower-1');
|
||||
agent.registerGrowerProfile(profile);
|
||||
|
||||
const retrieved = agent.getGrowerProfile('grower-1');
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved?.growerId).toBe('grower-1');
|
||||
});
|
||||
|
||||
it('should return null for unknown grower', () => {
|
||||
const retrieved = agent.getGrowerProfile('unknown-grower');
|
||||
expect(retrieved).toBeNull();
|
||||
});
|
||||
|
||||
it('should update existing profile', () => {
|
||||
const profile1 = createGrowerProfile('grower-1');
|
||||
profile1.experienceLevel = 'beginner';
|
||||
agent.registerGrowerProfile(profile1);
|
||||
|
||||
const profile2 = createGrowerProfile('grower-1');
|
||||
profile2.experienceLevel = 'expert';
|
||||
agent.registerGrowerProfile(profile2);
|
||||
|
||||
const retrieved = agent.getGrowerProfile('grower-1');
|
||||
expect(retrieved?.experienceLevel).toBe('expert');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Recommendations', () => {
|
||||
it('should return empty recommendations for unknown grower', () => {
|
||||
const recs = agent.getRecommendations('unknown-grower');
|
||||
expect(recs).toEqual([]);
|
||||
});
|
||||
|
||||
it('should get recommendations after profile registration', () => {
|
||||
const profile = createGrowerProfile('grower-1');
|
||||
agent.registerGrowerProfile(profile);
|
||||
|
||||
// Recommendations are generated during runOnce
|
||||
const recs = agent.getRecommendations('grower-1');
|
||||
expect(Array.isArray(recs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rotation Advice', () => {
|
||||
it('should return null for unknown grower', () => {
|
||||
const advice = agent.getRotationAdvice('unknown-grower');
|
||||
expect(advice).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Market Opportunities', () => {
|
||||
it('should return array of opportunities', () => {
|
||||
const opps = agent.getOpportunities();
|
||||
expect(Array.isArray(opps)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grower Performance', () => {
|
||||
it('should return null for unknown grower', () => {
|
||||
const perf = agent.getPerformance('unknown-grower');
|
||||
expect(perf).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Seasonal Alerts', () => {
|
||||
it('should return array of seasonal alerts', () => {
|
||||
const alerts = agent.getSeasonalAlerts();
|
||||
expect(Array.isArray(alerts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Lifecycle', () => {
|
||||
it('should start and change status to running', async () => {
|
||||
await agent.start();
|
||||
expect(agent.status).toBe('running');
|
||||
await agent.stop();
|
||||
});
|
||||
|
||||
it('should stop and change status to idle', async () => {
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should pause when running', async () => {
|
||||
await agent.start();
|
||||
agent.pause();
|
||||
expect(agent.status).toBe('paused');
|
||||
await agent.stop();
|
||||
});
|
||||
|
||||
it('should resume after pause', async () => {
|
||||
await agent.start();
|
||||
agent.pause();
|
||||
agent.resume();
|
||||
expect(agent.status).toBe('running');
|
||||
await agent.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton', () => {
|
||||
it('should return same instance from getGrowerAdvisoryAgent', () => {
|
||||
const agent1 = getGrowerAdvisoryAgent();
|
||||
const agent2 = getGrowerAdvisoryAgent();
|
||||
expect(agent1).toBe(agent2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alerts', () => {
|
||||
it('should return alerts array', () => {
|
||||
const alerts = agent.getAlerts();
|
||||
expect(Array.isArray(alerts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Execution', () => {
|
||||
it('should execute runOnce successfully', async () => {
|
||||
const profile = createGrowerProfile('grower-1');
|
||||
agent.registerGrowerProfile(profile);
|
||||
|
||||
const result = await agent.runOnce();
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe('completed');
|
||||
expect(result?.type).toBe('grower_advisory');
|
||||
});
|
||||
|
||||
it('should report metrics in task result', async () => {
|
||||
const profile = createGrowerProfile('grower-1');
|
||||
agent.registerGrowerProfile(profile);
|
||||
|
||||
const result = await agent.runOnce();
|
||||
|
||||
expect(result?.result).toHaveProperty('growersAdvised');
|
||||
expect(result?.result).toHaveProperty('recommendationsGenerated');
|
||||
expect(result?.result).toHaveProperty('opportunitiesIdentified');
|
||||
expect(result?.result).toHaveProperty('alertsGenerated');
|
||||
});
|
||||
|
||||
it('should count registered growers', async () => {
|
||||
agent.registerGrowerProfile(createGrowerProfile('grower-1'));
|
||||
agent.registerGrowerProfile(createGrowerProfile('grower-2'));
|
||||
agent.registerGrowerProfile(createGrowerProfile('grower-3'));
|
||||
|
||||
const result = await agent.runOnce();
|
||||
|
||||
expect(result?.result?.growersAdvised).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create test grower profiles
|
||||
function createGrowerProfile(
|
||||
growerId: string,
|
||||
lat: number = 40.7128,
|
||||
lon: number = -74.006
|
||||
) {
|
||||
return {
|
||||
growerId,
|
||||
growerName: `Test Grower ${growerId}`,
|
||||
location: { latitude: lat, longitude: lon },
|
||||
availableSpaceSqm: 100,
|
||||
specializations: ['lettuce', 'tomato'],
|
||||
certifications: ['organic'],
|
||||
experienceLevel: 'intermediate' as const,
|
||||
preferredCrops: ['lettuce', 'tomato', 'basil'],
|
||||
growingHistory: [
|
||||
{ cropType: 'lettuce', successRate: 85, avgYield: 4.5 },
|
||||
{ cropType: 'tomato', successRate: 75, avgYield: 8.0 },
|
||||
],
|
||||
};
|
||||
}
|
||||
48
__tests__/setup.ts
Normal file
48
__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Jest Test Setup
|
||||
* Global configuration and utilities for all tests
|
||||
*/
|
||||
|
||||
// Extend Jest matchers if needed
|
||||
// import '@testing-library/jest-dom';
|
||||
|
||||
// Mock console methods to reduce noise in tests
|
||||
const originalConsole = { ...console };
|
||||
|
||||
beforeAll(() => {
|
||||
// Suppress console.log during tests unless DEBUG is set
|
||||
if (!process.env.DEBUG) {
|
||||
console.log = jest.fn();
|
||||
console.info = jest.fn();
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore console
|
||||
console.log = originalConsole.log;
|
||||
console.info = originalConsole.info;
|
||||
});
|
||||
|
||||
// Global timeout for async operations
|
||||
jest.setTimeout(10000);
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Utility function for creating mock dates
|
||||
export function mockDate(date: Date | string): void {
|
||||
const mockDateValue = new Date(date);
|
||||
jest.spyOn(global, 'Date').mockImplementation(() => mockDateValue as any);
|
||||
}
|
||||
|
||||
// Utility function for waiting in tests
|
||||
export function wait(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Utility to create test IDs
|
||||
export function createTestId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
317
__tests__/unit/agents/AgentOrchestrator.test.ts
Normal file
317
__tests__/unit/agents/AgentOrchestrator.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
/**
|
||||
* AgentOrchestrator Tests
|
||||
* Tests for the agent orchestration system
|
||||
*/
|
||||
|
||||
import { AgentOrchestrator, getOrchestrator } from '../../../lib/agents/AgentOrchestrator';
|
||||
|
||||
// Mock all agents
|
||||
jest.mock('../../../lib/agents/PlantLineageAgent', () => ({
|
||||
PlantLineageAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'plant-lineage-agent', name: 'Plant Lineage Agent', priority: 'high' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'plant-lineage-agent',
|
||||
tasksCompleted: 5,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 100,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getPlantLineageAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/TransportTrackerAgent', () => ({
|
||||
TransportTrackerAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'transport-tracker-agent', name: 'Transport Tracker Agent', priority: 'high' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'transport-tracker-agent',
|
||||
tasksCompleted: 3,
|
||||
tasksFailed: 1,
|
||||
averageExecutionMs: 150,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getTransportTrackerAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/DemandForecastAgent', () => ({
|
||||
DemandForecastAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'demand-forecast-agent', name: 'Demand Forecast Agent', priority: 'high' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'demand-forecast-agent',
|
||||
tasksCompleted: 2,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 200,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getDemandForecastAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/VerticalFarmAgent', () => ({
|
||||
VerticalFarmAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'vertical-farm-agent', name: 'Vertical Farm Agent', priority: 'critical' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'vertical-farm-agent',
|
||||
tasksCompleted: 10,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 50,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getVerticalFarmAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/EnvironmentAnalysisAgent', () => ({
|
||||
EnvironmentAnalysisAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'environment-analysis-agent', name: 'Environment Analysis Agent', priority: 'medium' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'environment-analysis-agent',
|
||||
tasksCompleted: 1,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 300,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getEnvironmentAnalysisAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/MarketMatchingAgent', () => ({
|
||||
MarketMatchingAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'market-matching-agent', name: 'Market Matching Agent', priority: 'high' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'market-matching-agent',
|
||||
tasksCompleted: 4,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 120,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getMarketMatchingAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/SustainabilityAgent', () => ({
|
||||
SustainabilityAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'sustainability-agent', name: 'Sustainability Agent', priority: 'medium' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'sustainability-agent',
|
||||
tasksCompleted: 1,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 400,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getSustainabilityAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/NetworkDiscoveryAgent', () => ({
|
||||
NetworkDiscoveryAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'network-discovery-agent', name: 'Network Discovery Agent', priority: 'medium' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'network-discovery-agent',
|
||||
tasksCompleted: 1,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 500,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getNetworkDiscoveryAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/QualityAssuranceAgent', () => ({
|
||||
QualityAssuranceAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'quality-assurance-agent', name: 'Quality Assurance Agent', priority: 'critical' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'quality-assurance-agent',
|
||||
tasksCompleted: 8,
|
||||
tasksFailed: 0,
|
||||
averageExecutionMs: 80,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getQualityAssuranceAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../lib/agents/GrowerAdvisoryAgent', () => ({
|
||||
GrowerAdvisoryAgent: jest.fn().mockImplementation(() => ({
|
||||
config: { id: 'grower-advisory-agent', name: 'Grower Advisory Agent', priority: 'high' },
|
||||
status: 'idle',
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
stop: jest.fn().mockResolvedValue(undefined),
|
||||
getMetrics: jest.fn().mockReturnValue({
|
||||
agentId: 'grower-advisory-agent',
|
||||
tasksCompleted: 6,
|
||||
tasksFailed: 1,
|
||||
averageExecutionMs: 180,
|
||||
errors: [],
|
||||
}),
|
||||
getAlerts: jest.fn().mockReturnValue([]),
|
||||
})),
|
||||
getGrowerAdvisoryAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('AgentOrchestrator', () => {
|
||||
let orchestrator: AgentOrchestrator;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset singleton
|
||||
(globalThis as any).__orchestratorInstance = undefined;
|
||||
orchestrator = new AgentOrchestrator();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const status = orchestrator.getStatus();
|
||||
if (status.isRunning) {
|
||||
await orchestrator.stopAll();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with all 10 agents', () => {
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status.totalAgents).toBe(10);
|
||||
});
|
||||
|
||||
it('should not be running initially', () => {
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should have no running agents initially', () => {
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status.runningAgents).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Starting Agents', () => {
|
||||
it('should start all agents', async () => {
|
||||
await orchestrator.startAll();
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it('should start individual agent', async () => {
|
||||
await orchestrator.startAgent('plant-lineage-agent');
|
||||
const health = orchestrator.getAgentHealth('plant-lineage-agent');
|
||||
expect(health).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stopping Agents', () => {
|
||||
it('should stop all agents', async () => {
|
||||
await orchestrator.startAll();
|
||||
await orchestrator.stopAll();
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should stop individual agent', async () => {
|
||||
await orchestrator.startAgent('plant-lineage-agent');
|
||||
await orchestrator.stopAgent('plant-lineage-agent');
|
||||
// Agent should be stopped
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Health', () => {
|
||||
it('should return health for existing agent', async () => {
|
||||
await orchestrator.startAll();
|
||||
const health = orchestrator.getAgentHealth('plant-lineage-agent');
|
||||
expect(health).toHaveProperty('agentId');
|
||||
expect(health?.agentId).toBe('plant-lineage-agent');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent agent', () => {
|
||||
const health = orchestrator.getAgentHealth('non-existent-agent');
|
||||
expect(health).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status', () => {
|
||||
it('should return correct status structure', () => {
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status).toHaveProperty('isRunning');
|
||||
expect(status).toHaveProperty('totalAgents');
|
||||
expect(status).toHaveProperty('runningAgents');
|
||||
expect(status).toHaveProperty('healthyAgents');
|
||||
});
|
||||
|
||||
it('should track uptime when running', async () => {
|
||||
await orchestrator.startAll();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const status = orchestrator.getStatus();
|
||||
expect(status.uptime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dashboard', () => {
|
||||
it('should return dashboard data', async () => {
|
||||
await orchestrator.startAll();
|
||||
const dashboard = orchestrator.getDashboard();
|
||||
expect(dashboard).toHaveProperty('status');
|
||||
expect(dashboard).toHaveProperty('agents');
|
||||
expect(dashboard).toHaveProperty('recentAlerts');
|
||||
});
|
||||
|
||||
it('should include all agents in dashboard', async () => {
|
||||
await orchestrator.startAll();
|
||||
const dashboard = orchestrator.getDashboard();
|
||||
expect(dashboard.agents.length).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alerts', () => {
|
||||
it('should return empty alerts initially', () => {
|
||||
const alerts = orchestrator.getAlerts();
|
||||
expect(alerts).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter alerts by severity', () => {
|
||||
const criticalAlerts = orchestrator.getAlerts('critical');
|
||||
expect(Array.isArray(criticalAlerts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton', () => {
|
||||
it('should return same instance from getOrchestrator', () => {
|
||||
const orch1 = getOrchestrator();
|
||||
const orch2 = getOrchestrator();
|
||||
expect(orch1).toBe(orch2);
|
||||
});
|
||||
});
|
||||
});
|
||||
268
__tests__/unit/agents/BaseAgent.test.ts
Normal file
268
__tests__/unit/agents/BaseAgent.test.ts
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
/**
|
||||
* BaseAgent Tests
|
||||
* Tests for the abstract base agent class
|
||||
*/
|
||||
|
||||
import { BaseAgent } from '../../../lib/agents/BaseAgent';
|
||||
import { AgentConfig, AgentTask, AgentStatus } from '../../../lib/agents/types';
|
||||
|
||||
// Concrete implementation for testing
|
||||
class TestAgent extends BaseAgent {
|
||||
public runOnceResult: AgentTask | null = null;
|
||||
public runOnceError: Error | null = null;
|
||||
public runOnceCallCount = 0;
|
||||
|
||||
constructor(config?: Partial<AgentConfig>) {
|
||||
super({
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
description: 'Agent for testing',
|
||||
enabled: true,
|
||||
intervalMs: 100,
|
||||
priority: 'medium',
|
||||
maxRetries: 3,
|
||||
timeoutMs: 5000,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async runOnce(): Promise<AgentTask | null> {
|
||||
this.runOnceCallCount++;
|
||||
if (this.runOnceError) {
|
||||
throw this.runOnceError;
|
||||
}
|
||||
return this.runOnceResult;
|
||||
}
|
||||
|
||||
// Expose protected methods for testing
|
||||
public testCreateAlert(
|
||||
severity: 'info' | 'warning' | 'error' | 'critical',
|
||||
title: string,
|
||||
message: string
|
||||
) {
|
||||
return this.createAlert(severity, title, message);
|
||||
}
|
||||
|
||||
public testHandleError(error: Error) {
|
||||
return this.handleError(error);
|
||||
}
|
||||
|
||||
public testAddTask(type: string, payload: Record<string, any>) {
|
||||
return this.addTask(type, payload);
|
||||
}
|
||||
|
||||
public testCreateTaskResult(
|
||||
type: string,
|
||||
status: 'completed' | 'failed',
|
||||
result?: any
|
||||
) {
|
||||
return this.createTaskResult(type, status, result);
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseAgent', () => {
|
||||
let agent: TestAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
agent = new TestAgent();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (agent.status === 'running') {
|
||||
await agent.stop();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with correct config', () => {
|
||||
expect(agent.config.id).toBe('test-agent');
|
||||
expect(agent.config.name).toBe('Test Agent');
|
||||
expect(agent.config.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should initialize with idle status', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should initialize with empty metrics', () => {
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.tasksCompleted).toBe(0);
|
||||
expect(metrics.tasksFailed).toBe(0);
|
||||
expect(metrics.averageExecutionMs).toBe(0);
|
||||
expect(metrics.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize with empty alerts', () => {
|
||||
expect(agent.getAlerts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lifecycle', () => {
|
||||
it('should start and update status to running', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
expect(agent.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should not start twice', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
const firstCallCount = agent.runOnceCallCount;
|
||||
await agent.start(); // Second call
|
||||
expect(agent.runOnceCallCount).toBe(firstCallCount);
|
||||
});
|
||||
|
||||
it('should stop and update status to idle', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should pause and resume', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
agent.pause();
|
||||
expect(agent.status).toBe('paused');
|
||||
agent.resume();
|
||||
expect(agent.status).toBe('running');
|
||||
});
|
||||
|
||||
it('should not pause if not running', () => {
|
||||
agent.pause();
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('should not resume if not paused', () => {
|
||||
agent.resume();
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Execution', () => {
|
||||
it('should run task on start', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
expect(agent.runOnceCallCount).toBe(1);
|
||||
await agent.stop();
|
||||
});
|
||||
|
||||
it('should increment tasksCompleted on success', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed', { data: 'test' });
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
expect(agent.getMetrics().tasksCompleted).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment tasksFailed on failure', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'failed', null);
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
expect(agent.getMetrics().tasksFailed).toBe(1);
|
||||
});
|
||||
|
||||
it('should update lastRunAt after execution', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
expect(agent.getMetrics().lastRunAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle thrown errors', async () => {
|
||||
agent.runOnceError = new Error('Test error');
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.errors.length).toBe(1);
|
||||
expect(metrics.errors[0].message).toBe('Test error');
|
||||
});
|
||||
|
||||
it('should record error timestamp', async () => {
|
||||
agent.testHandleError(new Error('Test error'));
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.lastErrorAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should limit errors to 50', () => {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
agent.testHandleError(new Error(`Error ${i}`));
|
||||
}
|
||||
expect(agent.getMetrics().errors.length).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alerts', () => {
|
||||
it('should create alerts with correct structure', () => {
|
||||
const alert = agent.testCreateAlert('warning', 'Test Alert', 'Test message');
|
||||
expect(alert.id).toBeDefined();
|
||||
expect(alert.severity).toBe('warning');
|
||||
expect(alert.title).toBe('Test Alert');
|
||||
expect(alert.message).toBe('Test message');
|
||||
expect(alert.acknowledged).toBe(false);
|
||||
});
|
||||
|
||||
it('should add alerts to the list', () => {
|
||||
agent.testCreateAlert('info', 'Alert 1', 'Message 1');
|
||||
agent.testCreateAlert('warning', 'Alert 2', 'Message 2');
|
||||
expect(agent.getAlerts().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should limit alerts to 100', () => {
|
||||
for (let i = 0; i < 110; i++) {
|
||||
agent.testCreateAlert('info', `Alert ${i}`, 'Message');
|
||||
}
|
||||
expect(agent.getAlerts().length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics', () => {
|
||||
it('should calculate uptime when running', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.uptime).toBeGreaterThan(0);
|
||||
await agent.stop();
|
||||
});
|
||||
|
||||
it('should calculate average execution time', async () => {
|
||||
agent.runOnceResult = agent.testCreateTaskResult('test', 'completed');
|
||||
await agent.start();
|
||||
await agent.stop();
|
||||
const metrics = agent.getMetrics();
|
||||
expect(metrics.averageExecutionMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Queue', () => {
|
||||
it('should add tasks with unique IDs', () => {
|
||||
const task1 = agent.testAddTask('type1', { key: 'value1' });
|
||||
const task2 = agent.testAddTask('type2', { key: 'value2' });
|
||||
expect(task1.id).not.toBe(task2.id);
|
||||
});
|
||||
|
||||
it('should set correct task properties', () => {
|
||||
const task = agent.testAddTask('test-type', { data: 'value' });
|
||||
expect(task.type).toBe('test-type');
|
||||
expect(task.payload).toEqual({ data: 'value' });
|
||||
expect(task.status).toBe('pending');
|
||||
expect(task.agentId).toBe('test-agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Results', () => {
|
||||
it('should create completed task result', () => {
|
||||
const result = agent.testCreateTaskResult('scan', 'completed', { count: 10 });
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.result).toEqual({ count: 10 });
|
||||
});
|
||||
|
||||
it('should create failed task result', () => {
|
||||
const result = agent.testCreateTaskResult('scan', 'failed', null);
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
241
__tests__/unit/agents/PlantLineageAgent.test.ts
Normal file
241
__tests__/unit/agents/PlantLineageAgent.test.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
/**
|
||||
* PlantLineageAgent Tests
|
||||
* Tests for the plant lineage tracking agent
|
||||
*/
|
||||
|
||||
import { PlantLineageAgent, getPlantLineageAgent } from '../../../lib/agents/PlantLineageAgent';
|
||||
|
||||
// Mock the blockchain manager
|
||||
jest.mock('../../../lib/blockchain/manager', () => ({
|
||||
getBlockchain: jest.fn(() => ({
|
||||
getChain: jest.fn(() => [
|
||||
// Genesis block
|
||||
{
|
||||
index: 0,
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
plant: { id: 'genesis' },
|
||||
previousHash: '0',
|
||||
hash: 'genesis-hash',
|
||||
nonce: 0,
|
||||
},
|
||||
// First generation plant
|
||||
{
|
||||
index: 1,
|
||||
timestamp: '2024-01-02T00:00:00Z',
|
||||
plant: {
|
||||
id: 'plant-1',
|
||||
name: 'Original Tomato',
|
||||
species: 'Tomato',
|
||||
variety: 'Cherry',
|
||||
generation: 1,
|
||||
propagationType: 'original',
|
||||
parentPlantId: undefined,
|
||||
childPlants: ['plant-2'],
|
||||
status: 'thriving',
|
||||
dateAcquired: '2024-01-02',
|
||||
location: { latitude: 40.7128, longitude: -74.006 },
|
||||
environment: { light: 'full_sun' },
|
||||
growthMetrics: { height: 50 },
|
||||
},
|
||||
previousHash: 'genesis-hash',
|
||||
hash: 'hash-1',
|
||||
nonce: 1,
|
||||
},
|
||||
// Second generation plant
|
||||
{
|
||||
index: 2,
|
||||
timestamp: '2024-01-15T00:00:00Z',
|
||||
plant: {
|
||||
id: 'plant-2',
|
||||
name: 'Cloned Tomato',
|
||||
species: 'Tomato',
|
||||
variety: 'Cherry',
|
||||
generation: 2,
|
||||
propagationType: 'cutting',
|
||||
parentPlantId: 'plant-1',
|
||||
childPlants: ['plant-3'],
|
||||
status: 'healthy',
|
||||
dateAcquired: '2024-01-15',
|
||||
location: { latitude: 40.73, longitude: -73.99 },
|
||||
environment: { light: 'partial_sun' },
|
||||
growthMetrics: { height: 30 },
|
||||
},
|
||||
previousHash: 'hash-1',
|
||||
hash: 'hash-2',
|
||||
nonce: 2,
|
||||
},
|
||||
// Third generation plant
|
||||
{
|
||||
index: 3,
|
||||
timestamp: '2024-02-01T00:00:00Z',
|
||||
plant: {
|
||||
id: 'plant-3',
|
||||
name: 'Third Gen Tomato',
|
||||
species: 'Tomato',
|
||||
variety: 'Cherry',
|
||||
generation: 3,
|
||||
propagationType: 'seed',
|
||||
parentPlantId: 'plant-2',
|
||||
childPlants: [],
|
||||
status: 'healthy',
|
||||
dateAcquired: '2024-02-01',
|
||||
location: { latitude: 40.75, longitude: -73.98 },
|
||||
environment: { light: 'full_sun' },
|
||||
growthMetrics: { height: 20 },
|
||||
},
|
||||
previousHash: 'hash-2',
|
||||
hash: 'hash-3',
|
||||
nonce: 3,
|
||||
},
|
||||
]),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('PlantLineageAgent', () => {
|
||||
let agent: PlantLineageAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
agent = new PlantLineageAgent();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (agent.status === 'running') {
|
||||
await agent.stop();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with correct config', () => {
|
||||
expect(agent.config.id).toBe('plant-lineage-agent');
|
||||
expect(agent.config.name).toBe('Plant Lineage Agent');
|
||||
expect(agent.config.priority).toBe('high');
|
||||
expect(agent.config.intervalMs).toBe(60000);
|
||||
});
|
||||
|
||||
it('should start in idle status', () => {
|
||||
expect(agent.status).toBe('idle');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOnce', () => {
|
||||
it('should complete a scan cycle', async () => {
|
||||
const result = await agent.runOnce();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.status).toBe('completed');
|
||||
expect(result?.type).toBe('lineage_scan');
|
||||
});
|
||||
|
||||
it('should scan plants and update cache', async () => {
|
||||
await agent.runOnce();
|
||||
expect(agent.getLineageAnalysis('plant-1')).not.toBeNull();
|
||||
expect(agent.getLineageAnalysis('plant-2')).not.toBeNull();
|
||||
expect(agent.getLineageAnalysis('plant-3')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return scan statistics', async () => {
|
||||
const result = await agent.runOnce();
|
||||
expect(result?.result).toHaveProperty('plantsScanned');
|
||||
expect(result?.result).toHaveProperty('anomaliesFound');
|
||||
expect(result?.result).toHaveProperty('cacheSize');
|
||||
expect(result?.result.plantsScanned).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lineage Analysis', () => {
|
||||
beforeEach(async () => {
|
||||
await agent.runOnce();
|
||||
});
|
||||
|
||||
it('should find ancestors correctly', () => {
|
||||
const analysis = agent.getLineageAnalysis('plant-3');
|
||||
expect(analysis?.ancestors).toContain('plant-2');
|
||||
expect(analysis?.ancestors).toContain('plant-1');
|
||||
expect(analysis?.ancestors.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should find descendants correctly', () => {
|
||||
const analysis = agent.getLineageAnalysis('plant-1');
|
||||
expect(analysis?.descendants).toContain('plant-2');
|
||||
expect(analysis?.descendants).toContain('plant-3');
|
||||
});
|
||||
|
||||
it('should track generation depth', () => {
|
||||
const analysis1 = agent.getLineageAnalysis('plant-1');
|
||||
const analysis3 = agent.getLineageAnalysis('plant-3');
|
||||
expect(analysis1?.generation).toBe(1);
|
||||
expect(analysis3?.generation).toBe(3);
|
||||
});
|
||||
|
||||
it('should calculate lineage size', () => {
|
||||
const analysis = agent.getLineageAnalysis('plant-2');
|
||||
// plant-2 has 1 ancestor (plant-1) and 1 descendant (plant-3) + itself = 3
|
||||
expect(analysis?.totalLineageSize).toBe(3);
|
||||
});
|
||||
|
||||
it('should build propagation chain', () => {
|
||||
const analysis = agent.getLineageAnalysis('plant-3');
|
||||
expect(analysis?.propagationChain).toEqual(['original', 'cutting', 'seed']);
|
||||
});
|
||||
|
||||
it('should calculate health score', () => {
|
||||
const analysis = agent.getLineageAnalysis('plant-1');
|
||||
expect(analysis?.healthScore).toBeGreaterThan(0);
|
||||
expect(analysis?.healthScore).toBeLessThanOrEqual(100);
|
||||
});
|
||||
|
||||
it('should return null for non-existent plant', () => {
|
||||
const analysis = agent.getLineageAnalysis('non-existent');
|
||||
expect(analysis).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network Statistics', () => {
|
||||
beforeEach(async () => {
|
||||
await agent.runOnce();
|
||||
});
|
||||
|
||||
it('should calculate total plants', () => {
|
||||
const stats = agent.getNetworkStats();
|
||||
expect(stats.totalPlants).toBe(3);
|
||||
});
|
||||
|
||||
it('should calculate total lineages (root plants)', () => {
|
||||
const stats = agent.getNetworkStats();
|
||||
expect(stats.totalLineages).toBe(1); // Only plant-1 has no ancestors
|
||||
});
|
||||
|
||||
it('should calculate average generation depth', () => {
|
||||
const stats = agent.getNetworkStats();
|
||||
// (1 + 2 + 3) / 3 = 2
|
||||
expect(stats.avgGenerationDepth).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty stats when no data', () => {
|
||||
const emptyAgent = new PlantLineageAgent();
|
||||
const stats = emptyAgent.getNetworkStats();
|
||||
expect(stats.totalPlants).toBe(0);
|
||||
expect(stats.totalLineages).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anomaly Detection', () => {
|
||||
it('should return empty anomalies initially', () => {
|
||||
expect(agent.getAnomalies()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should detect anomalies during scan', async () => {
|
||||
await agent.runOnce();
|
||||
const anomalies = agent.getAnomalies();
|
||||
// The mock data is valid, so no anomalies should be detected
|
||||
expect(Array.isArray(anomalies)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Singleton', () => {
|
||||
it('should return same instance from getPlantLineageAgent', () => {
|
||||
const agent1 = getPlantLineageAgent();
|
||||
const agent2 = getPlantLineageAgent();
|
||||
expect(agent1).toBe(agent2);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
__tests__/unit/blockchain/PlantChain.test.ts
Normal file
169
__tests__/unit/blockchain/PlantChain.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* PlantChain Tests
|
||||
* Tests for the blockchain implementation
|
||||
*/
|
||||
|
||||
import { PlantChain } from '../../../lib/blockchain/PlantChain';
|
||||
import { PlantData } from '../../../lib/blockchain/types';
|
||||
|
||||
describe('PlantChain', () => {
|
||||
let chain: PlantChain;
|
||||
|
||||
beforeEach(() => {
|
||||
chain = new PlantChain();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should create chain with genesis block', () => {
|
||||
expect(chain.getChain().length).toBe(1);
|
||||
});
|
||||
|
||||
it('should have valid genesis block', () => {
|
||||
const genesisBlock = chain.getChain()[0];
|
||||
expect(genesisBlock.index).toBe(0);
|
||||
expect(genesisBlock.previousHash).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Adding Plants', () => {
|
||||
const createTestPlant = (overrides?: Partial<PlantData>): PlantData => ({
|
||||
id: `plant-${Date.now()}`,
|
||||
name: 'Test Plant',
|
||||
species: 'Test Species',
|
||||
variety: 'Test Variety',
|
||||
generation: 1,
|
||||
propagationType: 'original',
|
||||
dateAcquired: new Date().toISOString(),
|
||||
location: {
|
||||
latitude: 40.7128,
|
||||
longitude: -74.006,
|
||||
city: 'New York',
|
||||
},
|
||||
status: 'healthy',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('should add new plant to chain', () => {
|
||||
const plant = createTestPlant();
|
||||
chain.addPlant(plant);
|
||||
expect(chain.getChain().length).toBe(2);
|
||||
});
|
||||
|
||||
it('should generate valid block hash', () => {
|
||||
const plant = createTestPlant();
|
||||
chain.addPlant(plant);
|
||||
const newBlock = chain.getChain()[1];
|
||||
expect(newBlock.hash).toBeDefined();
|
||||
expect(newBlock.hash.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should link blocks correctly', () => {
|
||||
const plant = createTestPlant();
|
||||
chain.addPlant(plant);
|
||||
const genesisBlock = chain.getChain()[0];
|
||||
const newBlock = chain.getChain()[1];
|
||||
expect(newBlock.previousHash).toBe(genesisBlock.hash);
|
||||
});
|
||||
|
||||
it('should store plant data correctly', () => {
|
||||
const plant = createTestPlant({ name: 'My Tomato' });
|
||||
chain.addPlant(plant);
|
||||
const newBlock = chain.getChain()[1];
|
||||
expect(newBlock.plant.name).toBe('My Tomato');
|
||||
});
|
||||
|
||||
it('should add multiple plants', () => {
|
||||
chain.addPlant(createTestPlant({ id: 'plant-1' }));
|
||||
chain.addPlant(createTestPlant({ id: 'plant-2' }));
|
||||
chain.addPlant(createTestPlant({ id: 'plant-3' }));
|
||||
expect(chain.getChain().length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Finding Plants', () => {
|
||||
beforeEach(() => {
|
||||
chain.addPlant({
|
||||
id: 'tomato-1',
|
||||
name: 'Cherry Tomato',
|
||||
species: 'Tomato',
|
||||
variety: 'Cherry',
|
||||
generation: 1,
|
||||
propagationType: 'original',
|
||||
dateAcquired: new Date().toISOString(),
|
||||
location: { latitude: 40.7, longitude: -74.0 },
|
||||
status: 'healthy',
|
||||
});
|
||||
chain.addPlant({
|
||||
id: 'basil-1',
|
||||
name: 'Sweet Basil',
|
||||
species: 'Basil',
|
||||
variety: 'Genovese',
|
||||
generation: 1,
|
||||
propagationType: 'seed',
|
||||
dateAcquired: new Date().toISOString(),
|
||||
location: { latitude: 40.8, longitude: -73.9 },
|
||||
status: 'thriving',
|
||||
});
|
||||
});
|
||||
|
||||
it('should find plant by ID', () => {
|
||||
const block = chain.findPlant('tomato-1');
|
||||
expect(block).toBeDefined();
|
||||
expect(block?.plant.name).toBe('Cherry Tomato');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent plant', () => {
|
||||
const block = chain.findPlant('non-existent');
|
||||
expect(block).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chain Validation', () => {
|
||||
it('should validate empty chain (genesis only)', () => {
|
||||
expect(chain.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate chain with plants', () => {
|
||||
chain.addPlant({
|
||||
id: 'plant-1',
|
||||
name: 'Test Plant',
|
||||
species: 'Test',
|
||||
variety: 'Test',
|
||||
generation: 1,
|
||||
propagationType: 'original',
|
||||
dateAcquired: new Date().toISOString(),
|
||||
location: { latitude: 40.7, longitude: -74.0 },
|
||||
status: 'healthy',
|
||||
});
|
||||
expect(chain.isValid()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
beforeEach(() => {
|
||||
chain.addPlant({
|
||||
id: 'plant-1',
|
||||
name: 'Test Plant',
|
||||
species: 'Test',
|
||||
variety: 'Test',
|
||||
generation: 1,
|
||||
propagationType: 'original',
|
||||
dateAcquired: new Date().toISOString(),
|
||||
location: { latitude: 40.7, longitude: -74.0 },
|
||||
status: 'healthy',
|
||||
});
|
||||
});
|
||||
|
||||
it('should export to JSON', () => {
|
||||
const json = chain.toJSON();
|
||||
expect(json).toBeDefined();
|
||||
expect(Array.isArray(json)).toBe(true);
|
||||
});
|
||||
|
||||
it('should import from JSON', () => {
|
||||
const json = chain.toJSON();
|
||||
const restored = PlantChain.fromJSON(json);
|
||||
expect(restored.getChain().length).toBe(chain.getChain().length);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
bun.lock
178
bun.lock
|
|
@ -9,6 +9,8 @@
|
|||
"@tailwindcss/typography": "^0.5.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"classnames": "^2.3.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drupal-jsonapi-params": "^1.2.2",
|
||||
"html-react-parser": "^1.2.7",
|
||||
"next": "^12.2.3",
|
||||
|
|
@ -17,10 +19,12 @@
|
|||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.8.6",
|
||||
"recharts": "^3.4.1",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.0",
|
||||
|
|
@ -199,6 +203,8 @@
|
|||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.10.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.2.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" }, "optionalPeers": ["react", "react-redux"] }, "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="],
|
||||
|
|
@ -209,6 +215,10 @@
|
|||
|
||||
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.4.11", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw=="],
|
||||
|
||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.4.1", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A=="],
|
||||
|
|
@ -227,6 +237,70 @@
|
|||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
|
||||
|
||||
"@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
|
||||
|
||||
"@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
|
||||
|
||||
"@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
|
||||
|
||||
"@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
|
||||
|
||||
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
|
||||
|
||||
"@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
|
||||
|
||||
"@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
|
||||
|
||||
"@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
|
||||
|
||||
"@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
|
||||
|
||||
"@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
|
||||
|
||||
"@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
|
||||
|
||||
"@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
|
||||
|
||||
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
|
||||
|
||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
||||
|
||||
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||
|
||||
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
|
|
@ -249,6 +323,8 @@
|
|||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
|
||||
|
||||
"@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
|
||||
|
|
@ -377,6 +453,8 @@
|
|||
|
||||
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="],
|
||||
|
||||
"collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="],
|
||||
|
|
@ -399,6 +477,68 @@
|
|||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
|
||||
|
||||
"d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
|
||||
|
||||
"d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
|
||||
|
||||
"d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
||||
|
||||
"d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
|
||||
|
||||
"d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="],
|
||||
|
||||
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
|
||||
|
||||
"d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
|
||||
|
||||
"d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
|
||||
|
||||
"d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "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" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
|
||||
|
||||
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
||||
|
||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||
|
||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
|
@ -407,8 +547,12 @@
|
|||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"dedent": ["dedent@1.7.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
|
@ -419,6 +563,8 @@
|
|||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
|
||||
|
||||
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
|
@ -469,6 +615,8 @@
|
|||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
|
||||
|
||||
"es-toolkit": ["es-toolkit@1.42.0", "", {}, "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
|
@ -507,6 +655,8 @@
|
|||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"exit": ["exit@0.1.2", "", {}, "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ=="],
|
||||
|
|
@ -609,8 +759,12 @@
|
|||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
|
@ -625,6 +779,8 @@
|
|||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
|
@ -953,16 +1109,26 @@
|
|||
|
||||
"react-property": ["react-property@2.0.0", "", {}, "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw=="],
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "", { "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" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"recharts": ["recharts@3.4.1", "", { "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" }, "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" } }, "sha512-35kYg6JoOgwq8sE4rhYkVWwa6aAIgOtT+Ob0gitnShjwUwZmhrmy7Jco/5kJNF4PnLXgt9Hwq+geEMS+WrjU1g=="],
|
||||
|
||||
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
|
||||
|
||||
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "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" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "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" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="],
|
||||
|
|
@ -975,14 +1141,20 @@
|
|||
|
||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "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" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.20.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" } }, "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
|
@ -1075,6 +1247,8 @@
|
|||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
|
||||
|
|
@ -1121,6 +1295,8 @@
|
|||
|
||||
"v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
|
||||
|
||||
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
|
@ -1173,6 +1349,8 @@
|
|||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],
|
||||
|
||||
"eslint/doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||
|
|
|
|||
25
commitlint.config.js
Normal file
25
commitlint.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
[
|
||||
'feat', // New feature
|
||||
'fix', // Bug fix
|
||||
'docs', // Documentation
|
||||
'style', // Code style (formatting, semicolons, etc.)
|
||||
'refactor', // Code refactoring
|
||||
'perf', // Performance improvement
|
||||
'test', // Adding or updating tests
|
||||
'build', // Build system or dependencies
|
||||
'ci', // CI configuration
|
||||
'chore', // Maintenance tasks
|
||||
'revert', // Revert a previous commit
|
||||
],
|
||||
],
|
||||
'subject-case': [2, 'always', 'lower-case'],
|
||||
'subject-max-length': [2, 'always', 72],
|
||||
'body-max-line-length': [2, 'always', 100],
|
||||
},
|
||||
};
|
||||
|
|
@ -28,10 +28,11 @@ export default function EnvironmentalForm({
|
|||
section: K,
|
||||
updates: Partial<GrowingEnvironment[K]>
|
||||
) => {
|
||||
const currentSection = value[section] || {};
|
||||
onChange({
|
||||
...value,
|
||||
[section]: {
|
||||
...value[section],
|
||||
...(currentSection as object || {}),
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
229
components/analytics/DataTable.tsx
Normal file
229
components/analytics/DataTable.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Data Table Component
|
||||
* Sortable and filterable data table for analytics
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
|
||||
interface Column {
|
||||
key: string;
|
||||
header: string;
|
||||
sortable?: boolean;
|
||||
render?: (value: any, row: any) => React.ReactNode;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
data: any[];
|
||||
columns: Column[];
|
||||
title?: string;
|
||||
pageSize?: number;
|
||||
showSearch?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
|
||||
export default function DataTable({
|
||||
data,
|
||||
columns,
|
||||
title,
|
||||
pageSize = 10,
|
||||
showSearch = true,
|
||||
searchPlaceholder = 'Search...',
|
||||
}: DataTableProps) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDirection>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!search) return data;
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
return data.filter((row) =>
|
||||
columns.some((col) => {
|
||||
const value = row[col.key];
|
||||
return String(value).toLowerCase().includes(searchLower);
|
||||
})
|
||||
);
|
||||
}, [data, columns, search]);
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortKey || !sortDir) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aVal = a[sortKey];
|
||||
const bVal = b[sortKey];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal === null || aVal === undefined) return 1;
|
||||
if (bVal === null || bVal === undefined) return -1;
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1;
|
||||
return sortDir === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [filteredData, sortKey, sortDir]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = page * pageSize;
|
||||
return sortedData.slice(start, start + pageSize);
|
||||
}, [sortedData, page, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
if (sortDir === 'asc') setSortDir('desc');
|
||||
else if (sortDir === 'desc') {
|
||||
setSortKey(null);
|
||||
setSortDir(null);
|
||||
}
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (key: string) => {
|
||||
if (sortKey !== key) {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (sortDir === 'asc') {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900">{title}</h3>}
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
className="pl-10 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-6 py-3 text-xs font-medium text-gray-500 uppercase tracking-wider ${
|
||||
alignClasses[col.align || 'left']
|
||||
} ${col.sortable !== false ? 'cursor-pointer hover:bg-gray-100' : ''}`}
|
||||
style={{ width: col.width }}
|
||||
onClick={() => col.sortable !== false && handleSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{col.header}</span>
|
||||
{col.sortable !== false && getSortIcon(col.key)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-6 py-8 text-center text-gray-500">
|
||||
No data available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="hover:bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${
|
||||
alignClasses[col.align || 'left']
|
||||
}`}
|
||||
>
|
||||
{col.render ? col.render(row[col.key], row) : row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
Showing {page * pageSize + 1} to {Math.min((page + 1) * pageSize, sortedData.length)} of{' '}
|
||||
{sortedData.length} results
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 border border-gray-200 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 border border-gray-200 rounded text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
components/analytics/DateRangePicker.tsx
Normal file
47
components/analytics/DateRangePicker.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Date Range Picker Component
|
||||
* Allows selection of time range for analytics
|
||||
*/
|
||||
|
||||
import { TimeRange } from '../../lib/analytics/types';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value: TimeRange;
|
||||
onChange: (range: TimeRange) => void;
|
||||
showCustom?: boolean;
|
||||
}
|
||||
|
||||
const timeRangeOptions: { value: TimeRange; label: string }[] = [
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: '90d', label: 'Last 90 days' },
|
||||
{ value: '365d', label: 'Last year' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
];
|
||||
|
||||
export default function DateRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
showCustom = false,
|
||||
}: DateRangePickerProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-500">Time range:</span>
|
||||
<div className="inline-flex rounded-lg border border-gray-200 bg-white">
|
||||
{timeRangeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
|
||||
value === option.value
|
||||
? 'bg-green-500 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
components/analytics/FilterPanel.tsx
Normal file
165
components/analytics/FilterPanel.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Filter Panel Component
|
||||
* Provides filtering options for analytics data
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface FilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface FilterConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'select' | 'multiselect' | 'search';
|
||||
options?: FilterOption[];
|
||||
}
|
||||
|
||||
interface FilterPanelProps {
|
||||
filters: FilterConfig[];
|
||||
values: Record<string, any>;
|
||||
onChange: (values: Record<string, any>) => void;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export default function FilterPanel({
|
||||
filters,
|
||||
values,
|
||||
onChange,
|
||||
onReset,
|
||||
}: FilterPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
onChange({ ...values, [key]: value });
|
||||
};
|
||||
|
||||
const handleMultiSelect = (key: string, value: string) => {
|
||||
const current = values[key] || [];
|
||||
const updated = current.includes(value)
|
||||
? current.filter((v: string) => v !== value)
|
||||
: [...current, value];
|
||||
handleChange(key, updated);
|
||||
};
|
||||
|
||||
const activeFilterCount = Object.values(values).filter(
|
||||
(v) => v && (Array.isArray(v) ? v.length > 0 : true)
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow border border-gray-200">
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-gray-50"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium text-gray-700">Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transform transition-transform ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Filter content */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 py-4 border-t border-gray-200 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{filter.label}
|
||||
</label>
|
||||
{filter.type === 'select' && filter.options && (
|
||||
<select
|
||||
value={values[filter.key] || ''}
|
||||
onChange={(e) => handleChange(filter.key, e.target.value || null)}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{filter.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{filter.type === 'multiselect' && filter.options && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filter.options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleMultiSelect(filter.key, opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
(values[filter.key] || []).includes(opt.value)
|
||||
? 'bg-green-500 text-white border-green-500'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-green-300'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filter.type === 'search' && (
|
||||
<input
|
||||
type="text"
|
||||
value={values[filter.key] || ''}
|
||||
onChange={(e) => handleChange(filter.key, e.target.value || null)}
|
||||
placeholder={`Search ${filter.label.toLowerCase()}...`}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-2 pt-2 border-t border-gray-100">
|
||||
{onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
Reset filters
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="px-4 py-2 text-sm bg-green-500 text-white rounded-lg hover:bg-green-600"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
components/analytics/KPICard.tsx
Normal file
129
components/analytics/KPICard.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* KPI Card Component
|
||||
* Displays key performance indicators with trend indicators
|
||||
*/
|
||||
|
||||
import { TrendDirection } from '../../lib/analytics/types';
|
||||
|
||||
interface KPICardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
unit?: string;
|
||||
change?: number;
|
||||
changePercent?: number;
|
||||
trend?: TrendDirection;
|
||||
color?: 'green' | 'blue' | 'purple' | 'orange' | 'red' | 'teal';
|
||||
icon?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
green: {
|
||||
bg: 'bg-green-50',
|
||||
text: 'text-green-600',
|
||||
icon: 'text-green-500',
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-50',
|
||||
text: 'text-blue-600',
|
||||
icon: 'text-blue-500',
|
||||
},
|
||||
purple: {
|
||||
bg: 'bg-purple-50',
|
||||
text: 'text-purple-600',
|
||||
icon: 'text-purple-500',
|
||||
},
|
||||
orange: {
|
||||
bg: 'bg-orange-50',
|
||||
text: 'text-orange-600',
|
||||
icon: 'text-orange-500',
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-50',
|
||||
text: 'text-red-600',
|
||||
icon: 'text-red-500',
|
||||
},
|
||||
teal: {
|
||||
bg: 'bg-teal-50',
|
||||
text: 'text-teal-600',
|
||||
icon: 'text-teal-500',
|
||||
},
|
||||
};
|
||||
|
||||
export default function KPICard({
|
||||
title,
|
||||
value,
|
||||
unit,
|
||||
change,
|
||||
changePercent,
|
||||
trend = 'stable',
|
||||
color = 'green',
|
||||
icon,
|
||||
loading = false,
|
||||
}: KPICardProps) {
|
||||
const classes = colorClasses[color];
|
||||
|
||||
const getTrendIcon = () => {
|
||||
if (trend === 'up') {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (trend === 'down') {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const getTrendColor = () => {
|
||||
if (trend === 'up') return 'text-green-600';
|
||||
if (trend === 'down') return 'text-red-600';
|
||||
return 'text-gray-500';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`${classes.bg} rounded-lg p-6 animate-pulse`}>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${classes.bg} rounded-lg p-6 transition-all hover:shadow-md`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
{icon && <span className={classes.icon}>{icon}</span>}
|
||||
</div>
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<p className={`text-3xl font-bold ${classes.text}`}>{value}</p>
|
||||
{unit && <span className="text-sm text-gray-500">{unit}</span>}
|
||||
</div>
|
||||
{(change !== undefined || changePercent !== undefined) && (
|
||||
<div className={`flex items-center mt-2 space-x-1 ${getTrendColor()}`}>
|
||||
{getTrendIcon()}
|
||||
<span className="text-sm font-medium">
|
||||
{changePercent !== undefined
|
||||
? `${changePercent > 0 ? '+' : ''}${changePercent.toFixed(1)}%`
|
||||
: change !== undefined
|
||||
? `${change > 0 ? '+' : ''}${change}`
|
||||
: ''}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">vs prev period</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
components/analytics/TrendIndicator.tsx
Normal file
105
components/analytics/TrendIndicator.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/**
|
||||
* Trend Indicator Component
|
||||
* Shows trend direction with visual indicators
|
||||
*/
|
||||
|
||||
import { TrendDirection } from '../../lib/analytics/types';
|
||||
|
||||
interface TrendIndicatorProps {
|
||||
direction: TrendDirection;
|
||||
value?: number;
|
||||
showLabel?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
};
|
||||
|
||||
const textSizeClasses = {
|
||||
sm: 'text-xs',
|
||||
md: 'text-sm',
|
||||
lg: 'text-base',
|
||||
};
|
||||
|
||||
export default function TrendIndicator({
|
||||
direction,
|
||||
value,
|
||||
showLabel = false,
|
||||
size = 'md',
|
||||
}: TrendIndicatorProps) {
|
||||
const iconSize = sizeClasses[size];
|
||||
const textSize = textSizeClasses[size];
|
||||
|
||||
const getColor = () => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return 'text-green-500';
|
||||
case 'down':
|
||||
return 'text-red-500';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return 'bg-green-100';
|
||||
case 'down':
|
||||
return 'bg-red-100';
|
||||
default:
|
||||
return 'bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return (
|
||||
<svg className={iconSize} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 11l5-5m0 0l5 5m-5-5v12" />
|
||||
</svg>
|
||||
);
|
||||
case 'down':
|
||||
return (
|
||||
<svg className={iconSize} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 13l-5 5m0 0l-5-5m5 5V6" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg className={iconSize} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getLabel = () => {
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
return 'Increasing';
|
||||
case 'down':
|
||||
return 'Decreasing';
|
||||
default:
|
||||
return 'Stable';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center space-x-1.5 px-2 py-1 rounded-full ${getBgColor()}`}>
|
||||
<span className={getColor()}>{getIcon()}</span>
|
||||
{value !== undefined && (
|
||||
<span className={`font-medium ${getColor()} ${textSize}`}>
|
||||
{value > 0 ? '+' : ''}{value.toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
{showLabel && (
|
||||
<span className={`${getColor()} ${textSize}`}>{getLabel()}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
components/analytics/charts/AreaChart.tsx
Normal file
98
components/analytics/charts/AreaChart.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* Area Chart Component
|
||||
* Displays time series data as a filled area chart
|
||||
*/
|
||||
|
||||
import {
|
||||
AreaChart as RechartsAreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface AreaChartProps {
|
||||
data: any[];
|
||||
xKey: string;
|
||||
yKey: string | string[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
height?: number;
|
||||
showGrid?: boolean;
|
||||
showLegend?: boolean;
|
||||
stacked?: boolean;
|
||||
gradient?: boolean;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444'];
|
||||
|
||||
export default function AreaChart({
|
||||
data,
|
||||
xKey,
|
||||
yKey,
|
||||
title,
|
||||
colors = DEFAULT_COLORS,
|
||||
height = 300,
|
||||
showGrid = true,
|
||||
showLegend = true,
|
||||
stacked = false,
|
||||
gradient = true,
|
||||
formatter = (value) => value.toLocaleString(),
|
||||
}: AreaChartProps) {
|
||||
const yKeys = Array.isArray(yKey) ? yKey : [yKey];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsAreaChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
{yKeys.map((key, index) => (
|
||||
<linearGradient key={key} id={`color${key}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={colors[index % colors.length]} stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor={colors[index % colors.length]} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />}
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={formatter}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
formatter={(value: number) => [formatter(value), '']}
|
||||
/>
|
||||
{showLegend && <Legend />}
|
||||
{yKeys.map((key, index) => (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stackId={stacked ? 'stack' : undefined}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
fill={gradient ? `url(#color${key})` : colors[index % colors.length]}
|
||||
fillOpacity={gradient ? 1 : 0.6}
|
||||
/>
|
||||
))}
|
||||
</RechartsAreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
components/analytics/charts/BarChart.tsx
Normal file
99
components/analytics/charts/BarChart.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Bar Chart Component
|
||||
* Displays categorical data as bars
|
||||
*/
|
||||
|
||||
import {
|
||||
BarChart as RechartsBarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts';
|
||||
|
||||
interface BarChartProps {
|
||||
data: any[];
|
||||
xKey: string;
|
||||
yKey: string | string[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
height?: number;
|
||||
showGrid?: boolean;
|
||||
showLegend?: boolean;
|
||||
stacked?: boolean;
|
||||
horizontal?: boolean;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444', '#06b6d4'];
|
||||
|
||||
export default function BarChart({
|
||||
data,
|
||||
xKey,
|
||||
yKey,
|
||||
title,
|
||||
colors = DEFAULT_COLORS,
|
||||
height = 300,
|
||||
showGrid = true,
|
||||
showLegend = true,
|
||||
stacked = false,
|
||||
horizontal = false,
|
||||
formatter = (value) => value.toLocaleString(),
|
||||
}: BarChartProps) {
|
||||
const yKeys = Array.isArray(yKey) ? yKey : [yKey];
|
||||
const layout = horizontal ? 'vertical' : 'horizontal';
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsBarChart
|
||||
data={data}
|
||||
layout={layout}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />}
|
||||
{horizontal ? (
|
||||
<>
|
||||
<XAxis type="number" tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={formatter} />
|
||||
<YAxis dataKey={xKey} type="category" tick={{ fill: '#6b7280', fontSize: 12 }} width={100} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis dataKey={xKey} tick={{ fill: '#6b7280', fontSize: 12 }} />
|
||||
<YAxis tick={{ fill: '#6b7280', fontSize: 12 }} tickFormatter={formatter} />
|
||||
</>
|
||||
)}
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
formatter={(value: number) => [formatter(value), '']}
|
||||
/>
|
||||
{showLegend && yKeys.length > 1 && <Legend />}
|
||||
{yKeys.map((key, index) => (
|
||||
<Bar
|
||||
key={key}
|
||||
dataKey={key}
|
||||
fill={colors[index % colors.length]}
|
||||
stackId={stacked ? 'stack' : undefined}
|
||||
radius={[4, 4, 0, 0]}
|
||||
>
|
||||
{yKeys.length === 1 &&
|
||||
data.map((entry, i) => (
|
||||
<Cell key={`cell-${i}`} fill={colors[i % colors.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
))}
|
||||
</RechartsBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
components/analytics/charts/Gauge.tsx
Normal file
91
components/analytics/charts/Gauge.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Gauge Chart Component
|
||||
* Displays a single value as a gauge/meter
|
||||
*/
|
||||
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface GaugeProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
title?: string;
|
||||
unit?: string;
|
||||
size?: number;
|
||||
colors?: { low: string; medium: string; high: string };
|
||||
thresholds?: { low: number; high: number };
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = {
|
||||
low: '#ef4444',
|
||||
medium: '#f59e0b',
|
||||
high: '#10b981',
|
||||
};
|
||||
|
||||
export default function Gauge({
|
||||
value,
|
||||
max = 100,
|
||||
title,
|
||||
unit = '%',
|
||||
size = 200,
|
||||
colors = DEFAULT_COLORS,
|
||||
thresholds = { low: 33, high: 66 },
|
||||
}: GaugeProps) {
|
||||
const percentage = Math.min((value / max) * 100, 100);
|
||||
|
||||
// Determine color based on thresholds
|
||||
let color: string;
|
||||
if (percentage < thresholds.low) {
|
||||
color = colors.low;
|
||||
} else if (percentage < thresholds.high) {
|
||||
color = colors.medium;
|
||||
} else {
|
||||
color = colors.high;
|
||||
}
|
||||
|
||||
// Data for semi-circle gauge
|
||||
const gaugeData = [
|
||||
{ value: percentage, color },
|
||||
{ value: 100 - percentage, color: '#e5e7eb' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 flex flex-col items-center">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900 mb-2">{title}</h3>}
|
||||
<div className="relative" style={{ width: size, height: size / 2 + 20 }}>
|
||||
<ResponsiveContainer width="100%" height={size}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={gaugeData}
|
||||
cx="50%"
|
||||
cy="100%"
|
||||
startAngle={180}
|
||||
endAngle={0}
|
||||
innerRadius={size * 0.3}
|
||||
outerRadius={size * 0.4}
|
||||
paddingAngle={0}
|
||||
dataKey="value"
|
||||
>
|
||||
{gaugeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="none" />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div
|
||||
className="absolute inset-0 flex flex-col items-center justify-end pb-2"
|
||||
style={{ top: size * 0.2 }}
|
||||
>
|
||||
<span className="text-3xl font-bold" style={{ color }}>
|
||||
{value.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between w-full mt-2 px-4 text-xs text-gray-500">
|
||||
<span>0</span>
|
||||
<span>{max / 2}</span>
|
||||
<span>{max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
components/analytics/charts/Heatmap.tsx
Normal file
134
components/analytics/charts/Heatmap.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* Heatmap Component
|
||||
* Displays data intensity across a grid
|
||||
*/
|
||||
|
||||
interface HeatmapCell {
|
||||
x: string;
|
||||
y: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface HeatmapProps {
|
||||
data: HeatmapCell[];
|
||||
title?: string;
|
||||
xLabels: string[];
|
||||
yLabels: string[];
|
||||
colorRange?: { min: string; max: string };
|
||||
height?: number;
|
||||
showValues?: boolean;
|
||||
}
|
||||
|
||||
function interpolateColor(color1: string, color2: string, factor: number): string {
|
||||
const hex = (c: string) => parseInt(c, 16);
|
||||
const r1 = hex(color1.slice(1, 3));
|
||||
const g1 = hex(color1.slice(3, 5));
|
||||
const b1 = hex(color1.slice(5, 7));
|
||||
const r2 = hex(color2.slice(1, 3));
|
||||
const g2 = hex(color2.slice(3, 5));
|
||||
const b2 = hex(color2.slice(5, 7));
|
||||
|
||||
const r = Math.round(r1 + (r2 - r1) * factor);
|
||||
const g = Math.round(g1 + (g2 - g1) * factor);
|
||||
const b = Math.round(b1 + (b2 - b1) * factor);
|
||||
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export default function Heatmap({
|
||||
data,
|
||||
title,
|
||||
xLabels,
|
||||
yLabels,
|
||||
colorRange = { min: '#fee2e2', max: '#10b981' },
|
||||
height = 300,
|
||||
showValues = true,
|
||||
}: HeatmapProps) {
|
||||
const maxValue = Math.max(...data.map((d) => d.value));
|
||||
const minValue = Math.min(...data.map((d) => d.value));
|
||||
const range = maxValue - minValue || 1;
|
||||
|
||||
const getColor = (value: number): string => {
|
||||
const factor = (value - minValue) / range;
|
||||
return interpolateColor(colorRange.min, colorRange.max, factor);
|
||||
};
|
||||
|
||||
const getValue = (x: string, y: string): number | undefined => {
|
||||
const cell = data.find((d) => d.x === x && d.y === y);
|
||||
return cell?.value;
|
||||
};
|
||||
|
||||
const cellWidth = `${100 / xLabels.length}%`;
|
||||
const cellHeight = (height - 40) / yLabels.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
|
||||
<div style={{ height }}>
|
||||
{/* X Labels */}
|
||||
<div className="flex mb-1" style={{ paddingLeft: '80px' }}>
|
||||
{xLabels.map((label) => (
|
||||
<div
|
||||
key={label}
|
||||
className="text-xs text-gray-500 text-center truncate"
|
||||
style={{ width: cellWidth }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{yLabels.map((yLabel) => (
|
||||
<div key={yLabel} className="flex">
|
||||
<div
|
||||
className="flex items-center justify-end pr-2 text-xs text-gray-500"
|
||||
style={{ width: '80px' }}
|
||||
>
|
||||
{yLabel}
|
||||
</div>
|
||||
{xLabels.map((xLabel) => {
|
||||
const value = getValue(xLabel, yLabel);
|
||||
const bgColor = value !== undefined ? getColor(value) : '#f3f4f6';
|
||||
const textColor =
|
||||
value !== undefined && (value - minValue) / range > 0.5
|
||||
? '#fff'
|
||||
: '#374151';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${xLabel}-${yLabel}`}
|
||||
className="flex items-center justify-center border border-white rounded-sm transition-all hover:ring-2 hover:ring-gray-400"
|
||||
style={{
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
backgroundColor: bgColor,
|
||||
}}
|
||||
title={`${xLabel}, ${yLabel}: ${value ?? 'N/A'}`}
|
||||
>
|
||||
{showValues && value !== undefined && (
|
||||
<span className="text-xs font-medium" style={{ color: textColor }}>
|
||||
{value.toFixed(0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center justify-center mt-4 space-x-4">
|
||||
<span className="text-xs text-gray-500">Low</span>
|
||||
<div
|
||||
className="w-24 h-3 rounded"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${colorRange.min}, ${colorRange.max})`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-gray-500">High</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
components/analytics/charts/LineChart.tsx
Normal file
85
components/analytics/charts/LineChart.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Line Chart Component
|
||||
* Displays time series data as a line chart
|
||||
*/
|
||||
|
||||
import {
|
||||
LineChart as RechartsLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface LineChartProps {
|
||||
data: any[];
|
||||
xKey: string;
|
||||
yKey: string | string[];
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
height?: number;
|
||||
showGrid?: boolean;
|
||||
showLegend?: boolean;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = ['#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444'];
|
||||
|
||||
export default function LineChart({
|
||||
data,
|
||||
xKey,
|
||||
yKey,
|
||||
title,
|
||||
colors = DEFAULT_COLORS,
|
||||
height = 300,
|
||||
showGrid = true,
|
||||
showLegend = true,
|
||||
formatter = (value) => value.toLocaleString(),
|
||||
}: LineChartProps) {
|
||||
const yKeys = Array.isArray(yKey) ? yKey : [yKey];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsLineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
{showGrid && <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />}
|
||||
<XAxis
|
||||
dataKey={xKey}
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#e5e7eb' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 12 }}
|
||||
tickLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={formatter}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
formatter={(value: number) => [formatter(value), '']}
|
||||
/>
|
||||
{showLegend && <Legend />}
|
||||
{yKeys.map((key, index) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: colors[index % colors.length], r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
components/analytics/charts/PieChart.tsx
Normal file
123
components/analytics/charts/PieChart.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* Pie Chart Component
|
||||
* Displays distribution data as a pie chart
|
||||
*/
|
||||
|
||||
import {
|
||||
PieChart as RechartsPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface PieChartProps {
|
||||
data: any[];
|
||||
dataKey: string;
|
||||
nameKey: string;
|
||||
title?: string;
|
||||
colors?: string[];
|
||||
height?: number;
|
||||
showLegend?: boolean;
|
||||
innerRadius?: number;
|
||||
outerRadius?: number;
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS = [
|
||||
'#10b981', '#3b82f6', '#8b5cf6', '#f59e0b', '#ef4444',
|
||||
'#06b6d4', '#ec4899', '#84cc16', '#f97316', '#6366f1',
|
||||
];
|
||||
|
||||
export default function PieChart({
|
||||
data,
|
||||
dataKey,
|
||||
nameKey,
|
||||
title,
|
||||
colors = DEFAULT_COLORS,
|
||||
height = 300,
|
||||
showLegend = true,
|
||||
innerRadius = 0,
|
||||
outerRadius = 80,
|
||||
formatter = (value) => value.toLocaleString(),
|
||||
}: PieChartProps) {
|
||||
const RADIAN = Math.PI / 180;
|
||||
|
||||
const renderCustomizedLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}: any) => {
|
||||
if (percent < 0.05) return null;
|
||||
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
{title && <h3 className="text-lg font-bold text-gray-900 mb-4">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomizedLabel}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
paddingAngle={2}
|
||||
dataKey={dataKey}
|
||||
nameKey={nameKey}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={colors[index % colors.length]}
|
||||
stroke="#fff"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
formatter={(value: number) => [formatter(value), '']}
|
||||
/>
|
||||
{showLegend && (
|
||||
<Legend
|
||||
layout="horizontal"
|
||||
verticalAlign="bottom"
|
||||
align="center"
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
/>
|
||||
)}
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
components/analytics/charts/index.ts
Normal file
11
components/analytics/charts/index.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Chart Components Index
|
||||
* Export all chart components
|
||||
*/
|
||||
|
||||
export { default as LineChart } from './LineChart';
|
||||
export { default as BarChart } from './BarChart';
|
||||
export { default as PieChart } from './PieChart';
|
||||
export { default as AreaChart } from './AreaChart';
|
||||
export { default as Gauge } from './Gauge';
|
||||
export { default as Heatmap } from './Heatmap';
|
||||
|
|
@ -1,3 +1,19 @@
|
|||
/**
|
||||
* Analytics Components Index
|
||||
* Export all analytics components
|
||||
*/
|
||||
|
||||
// Charts
|
||||
export * from './charts';
|
||||
|
||||
// Widgets
|
||||
export { default as KPICard } from './KPICard';
|
||||
export { default as TrendIndicator } from './TrendIndicator';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as DateRangePicker } from './DateRangePicker';
|
||||
export { default as FilterPanel } from './FilterPanel';
|
||||
|
||||
// Existing components
|
||||
export { default as EnvironmentalImpact } from './EnvironmentalImpact';
|
||||
export { default as FoodMilesTracker } from './FoodMilesTracker';
|
||||
export { default as SavingsCalculator } from './SavingsCalculator';
|
||||
|
|
|
|||
128
components/auth/AuthGuard.tsx
Normal file
128
components/auth/AuthGuard.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useAuth } from '@/lib/auth/useAuth'
|
||||
import { UserRole } from '@/lib/auth/types'
|
||||
import { hasRole, hasPermission } from '@/lib/auth/permissions'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
requiredRole?: UserRole
|
||||
requiredPermission?: string
|
||||
fallback?: React.ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function AuthGuard({
|
||||
children,
|
||||
requiredRole,
|
||||
requiredPermission,
|
||||
fallback,
|
||||
redirectTo = '/auth/signin',
|
||||
}: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const returnUrl = encodeURIComponent(router.asPath)
|
||||
router.push(`${redirectTo}?callbackUrl=${returnUrl}`)
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router, redirectTo])
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600">Redirecting to sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check role requirement
|
||||
if (requiredRole && user) {
|
||||
if (!hasRole(user.role, requiredRole)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<div className="text-red-500 text-6xl mb-4">403</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You don't have permission to access this page. This page requires{' '}
|
||||
<span className="font-medium">{requiredRole}</span> role or higher.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-green-600 hover:text-green-500 font-medium"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check permission requirement
|
||||
if (requiredPermission && user) {
|
||||
if (!hasPermission(user.role, requiredPermission)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<div className="text-red-500 text-6xl mb-4">403</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You don't have the required permission to access this page.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-green-600 hover:text-green-500 font-medium"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Higher-order component version
|
||||
export function withAuthGuard<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
options?: {
|
||||
requiredRole?: UserRole
|
||||
requiredPermission?: string
|
||||
fallback?: React.ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
) {
|
||||
return function AuthGuardedComponent(props: P) {
|
||||
return (
|
||||
<AuthGuard {...options}>
|
||||
<Component {...props} />
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthGuard
|
||||
132
components/auth/LoginForm.tsx
Normal file
132
components/auth/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { useState } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface LoginFormProps {
|
||||
callbackUrl?: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function LoginForm({ callbackUrl = '/', onSuccess, onError }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
const errorMessage = getErrorMessage(result.error)
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} else if (result?.ok) {
|
||||
onSuccess?.()
|
||||
if (callbackUrl) {
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = 'An unexpected error occurred'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="login-email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="login-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="login-password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a className="font-medium text-green-600 hover:text-green-500">
|
||||
Forgot password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
CredentialsSignin: 'Invalid email or password',
|
||||
default: 'An error occurred during sign in',
|
||||
}
|
||||
return errorMessages[error] ?? errorMessages.default
|
||||
}
|
||||
|
||||
export default LoginForm
|
||||
142
components/auth/PasswordResetForm.tsx
Normal file
142
components/auth/PasswordResetForm.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
token: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function PasswordResetForm({ token, onSuccess, onError }: PasswordResetFormProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const validatePassword = (): string | null => {
|
||||
if (password.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(password)
|
||||
const hasLowerCase = /[a-z]/.test(password)
|
||||
const hasNumbers = /\d/.test(password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return 'Password must contain uppercase, lowercase, and numbers'
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return 'Passwords do not match'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const validationError = validatePassword()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
setIsLoading(false)
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'An error occurred')
|
||||
onError?.(data.message || 'An error occurred')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
const errorMessage = 'An unexpected error occurred'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Password Reset Successful!</h3>
|
||||
<p>Your password has been reset successfully.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="new-password" className="block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-700">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordResetForm
|
||||
195
components/auth/RegisterForm.tsx
Normal file
195
components/auth/RegisterForm.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useState } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
|
||||
interface RegisterFormProps {
|
||||
callbackUrl?: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function RegisterForm({ callbackUrl = '/', onSuccess, onError }: RegisterFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.email || !formData.password) {
|
||||
return 'Email and password are required'
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(formData.password)
|
||||
const hasLowerCase = /[a-z]/.test(formData.password)
|
||||
const hasNumbers = /\d/.test(formData.password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return 'Password must contain uppercase, lowercase, and numbers'
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
return 'Passwords do not match'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
setIsLoading(false)
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'Registration failed')
|
||||
onError?.(data.message || 'Registration failed')
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess?.()
|
||||
|
||||
// Auto sign in after successful registration
|
||||
const result = await signIn('credentials', {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.ok && callbackUrl) {
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = 'An unexpected error occurred'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="register-name" className="block text-sm font-medium text-gray-700">
|
||||
Full Name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="register-name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="register-email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="register-password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="register-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="register-confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="register-confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterForm
|
||||
95
components/auth/SocialLoginButtons.tsx
Normal file
95
components/auth/SocialLoginButtons.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { signIn } from 'next-auth/react'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
callbackUrl?: string
|
||||
providers?: string[]
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, { name: string; icon: JSX.Element; bgColor: string }> = {
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-gray-900 hover:bg-gray-800',
|
||||
},
|
||||
google: {
|
||||
name: 'Google',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-white hover:bg-gray-50 border border-gray-300',
|
||||
},
|
||||
}
|
||||
|
||||
export function SocialLoginButtons({ callbackUrl = '/', providers = ['github', 'google'] }: SocialLoginButtonsProps) {
|
||||
const handleSignIn = (providerId: string) => {
|
||||
signIn(providerId, { callbackUrl })
|
||||
}
|
||||
|
||||
const availableProviders = providers.filter((p) => p in providerConfig)
|
||||
|
||||
if (availableProviders.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{availableProviders.map((providerId) => {
|
||||
const config = providerConfig[providerId]
|
||||
const isGoogle = providerId === 'google'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
onClick={() => handleSignIn(providerId)}
|
||||
className={`w-full inline-flex justify-center items-center py-2 px-4 rounded-md shadow-sm text-sm font-medium ${
|
||||
config.bgColor
|
||||
} ${isGoogle ? 'text-gray-700' : 'text-white'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
|
||||
>
|
||||
<span className="mr-2">{config.icon}</span>
|
||||
Continue with {config.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SocialDivider() {
|
||||
return (
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialLoginButtons
|
||||
5
components/auth/index.ts
Normal file
5
components/auth/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { LoginForm } from './LoginForm'
|
||||
export { RegisterForm } from './RegisterForm'
|
||||
export { PasswordResetForm } from './PasswordResetForm'
|
||||
export { SocialLoginButtons, SocialDivider } from './SocialLoginButtons'
|
||||
export { AuthGuard, withAuthGuard } from './AuthGuard'
|
||||
149
components/marketplace/ListingCard.tsx
Normal file
149
components/marketplace/ListingCard.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import Link from 'next/link';
|
||||
|
||||
interface Listing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
category: string;
|
||||
sellerName?: string;
|
||||
location?: { city?: string; region?: string };
|
||||
tags: string[];
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
seeds: 'Seeds',
|
||||
seedlings: 'Seedlings',
|
||||
mature_plants: 'Mature Plants',
|
||||
cuttings: 'Cuttings',
|
||||
produce: 'Produce',
|
||||
supplies: 'Supplies',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
seeds: '🌰',
|
||||
seedlings: '🌱',
|
||||
mature_plants: '🪴',
|
||||
cuttings: '✂️',
|
||||
produce: '🥬',
|
||||
supplies: '🧰',
|
||||
};
|
||||
|
||||
interface ListingCardProps {
|
||||
listing: Listing;
|
||||
variant?: 'default' | 'compact' | 'featured';
|
||||
}
|
||||
|
||||
export function ListingCard({ listing, variant = 'default' }: ListingCardProps) {
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<Link href={`/marketplace/listings/${listing.id}`}>
|
||||
<a className="flex items-center gap-4 p-4 bg-white rounded-lg shadow hover:shadow-md transition border border-gray-200">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-green-100 to-emerald-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-2xl">{categoryIcons[listing.category] || '🌿'}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{listing.title}</h3>
|
||||
<p className="text-sm text-gray-500">{categoryLabels[listing.category]}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-green-600">${listing.price.toFixed(2)}</div>
|
||||
<div className="text-xs text-gray-500">{listing.quantity} avail.</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'featured') {
|
||||
return (
|
||||
<Link href={`/marketplace/listings/${listing.id}`}>
|
||||
<a className="block bg-white rounded-xl shadow-lg hover:shadow-xl transition overflow-hidden border-2 border-green-200">
|
||||
<div className="relative">
|
||||
<div className="h-56 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
|
||||
<span className="text-7xl">{categoryIcons[listing.category] || '🌿'}</span>
|
||||
</div>
|
||||
<div className="absolute top-4 left-4">
|
||||
<span className="px-3 py-1 bg-green-600 text-white text-sm font-medium rounded-full">
|
||||
Featured
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h3 className="text-xl font-bold text-gray-900 line-clamp-1">
|
||||
{listing.title}
|
||||
</h3>
|
||||
<span className="text-2xl font-bold text-green-600">
|
||||
${listing.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 line-clamp-2 mb-4">
|
||||
{listing.description}
|
||||
</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>{categoryLabels[listing.category]}</span>
|
||||
<span>•</span>
|
||||
<span>{listing.quantity} available</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-400">
|
||||
{listing.viewCount} views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<Link href={`/marketplace/listings/${listing.id}`}>
|
||||
<a className="block bg-white rounded-lg shadow hover:shadow-lg transition overflow-hidden border border-gray-200">
|
||||
<div className="h-48 bg-gradient-to-br from-green-100 to-emerald-100 flex items-center justify-center">
|
||||
<span className="text-6xl">{categoryIcons[listing.category] || '🌿'}</span>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1">
|
||||
{listing.title}
|
||||
</h3>
|
||||
<span className="text-lg font-bold text-green-600">
|
||||
${listing.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm line-clamp-2 mb-3">
|
||||
{listing.description}
|
||||
</p>
|
||||
<div className="flex justify-between items-center text-sm text-gray-500">
|
||||
<span>{categoryLabels[listing.category]}</span>
|
||||
<span>{listing.quantity} available</span>
|
||||
</div>
|
||||
{listing.sellerName && (
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
by {listing.sellerName}
|
||||
</div>
|
||||
)}
|
||||
{listing.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{listing.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListingCard;
|
||||
290
components/marketplace/ListingForm.tsx
Normal file
290
components/marketplace/ListingForm.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface ListingFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
price: string;
|
||||
quantity: string;
|
||||
category: string;
|
||||
tags: string;
|
||||
city: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
interface ListingFormProps {
|
||||
initialData?: Partial<ListingFormData>;
|
||||
onSubmit: (data: ListingFormData) => Promise<void>;
|
||||
submitLabel?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ value: 'seeds', label: 'Seeds', icon: '🌰', description: 'Plant seeds for growing' },
|
||||
{ value: 'seedlings', label: 'Seedlings', icon: '🌱', description: 'Young plants ready for transplanting' },
|
||||
{ value: 'mature_plants', label: 'Mature Plants', icon: '🪴', description: 'Fully grown plants' },
|
||||
{ value: 'cuttings', label: 'Cuttings', icon: '✂️', description: 'Plant cuttings for propagation' },
|
||||
{ value: 'produce', label: 'Produce', icon: '🥬', description: 'Fresh fruits and vegetables' },
|
||||
{ value: 'supplies', label: 'Supplies', icon: '🧰', description: 'Gardening tools and supplies' },
|
||||
];
|
||||
|
||||
export function ListingForm({
|
||||
initialData = {},
|
||||
onSubmit,
|
||||
submitLabel = 'Create Listing',
|
||||
isLoading = false,
|
||||
}: ListingFormProps) {
|
||||
const [formData, setFormData] = useState<ListingFormData>({
|
||||
title: initialData.title || '',
|
||||
description: initialData.description || '',
|
||||
price: initialData.price || '',
|
||||
quantity: initialData.quantity || '1',
|
||||
category: initialData.category || '',
|
||||
tags: initialData.tags || '',
|
||||
city: initialData.city || '',
|
||||
region: initialData.region || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof ListingFormData, string>>>({});
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
// Clear error when field is edited
|
||||
if (errors[name as keyof ListingFormData]) {
|
||||
setErrors((prev) => ({ ...prev, [name]: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof ListingFormData, string>> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
} else if (formData.title.length < 10) {
|
||||
newErrors.title = 'Title must be at least 10 characters';
|
||||
}
|
||||
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
} else if (formData.description.length < 20) {
|
||||
newErrors.description = 'Description must be at least 20 characters';
|
||||
}
|
||||
|
||||
if (!formData.price) {
|
||||
newErrors.price = 'Price is required';
|
||||
} else if (parseFloat(formData.price) <= 0) {
|
||||
newErrors.price = 'Price must be greater than 0';
|
||||
}
|
||||
|
||||
if (!formData.quantity) {
|
||||
newErrors.quantity = 'Quantity is required';
|
||||
} else if (parseInt(formData.quantity, 10) < 1) {
|
||||
newErrors.quantity = 'Quantity must be at least 1';
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
newErrors.category = 'Category is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Organic Tomato Seedlings - Cherokee Purple"
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||
errors.title ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description *
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
placeholder="Describe your item in detail. Include information about variety, growing conditions, care instructions, etc."
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||
errors.description ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.description && <p className="mt-1 text-sm text-red-600">{errors.description}</p>}
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{formData.description.length}/500 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category *
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.value}
|
||||
type="button"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, category: cat.value }))}
|
||||
className={`p-4 rounded-lg border-2 text-left transition ${
|
||||
formData.category === cat.value
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{cat.icon}</div>
|
||||
<div className="font-medium text-gray-900">{cat.label}</div>
|
||||
<div className="text-xs text-gray-500">{cat.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{errors.category && <p className="mt-2 text-sm text-red-600">{errors.category}</p>}
|
||||
</div>
|
||||
|
||||
{/* Price and Quantity */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Price (USD) *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
className={`w-full pl-8 pr-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||
errors.price ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.price && <p className="mt-1 text-sm text-red-600">{errors.price}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="quantity" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Quantity *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="quantity"
|
||||
name="quantity"
|
||||
value={formData.quantity}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent ${
|
||||
errors.quantity ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
{errors.quantity && <p className="mt-1 text-sm text-red-600">{errors.quantity}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
City (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., Portland"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="region" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
State/Region (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="region"
|
||||
name="region"
|
||||
value={formData.region}
|
||||
onChange={handleChange}
|
||||
placeholder="e.g., OR"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Tags (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
value={formData.tags}
|
||||
onChange={handleChange}
|
||||
placeholder="organic, heirloom, non-gmo (comma separated)"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Add tags to help buyers find your listing
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-4 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="animate-spin">⟳</span>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
submitLabel
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListingForm;
|
||||
64
components/marketplace/ListingGrid.tsx
Normal file
64
components/marketplace/ListingGrid.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { ListingCard } from './ListingCard';
|
||||
|
||||
interface Listing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
category: string;
|
||||
sellerName?: string;
|
||||
location?: { city?: string; region?: string };
|
||||
tags: string[];
|
||||
viewCount: number;
|
||||
}
|
||||
|
||||
interface ListingGridProps {
|
||||
listings: Listing[];
|
||||
columns?: 2 | 3 | 4;
|
||||
variant?: 'default' | 'compact' | 'featured';
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export function ListingGrid({
|
||||
listings,
|
||||
columns = 3,
|
||||
variant = 'default',
|
||||
emptyMessage = 'No listings found',
|
||||
}: ListingGridProps) {
|
||||
if (listings.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-white rounded-lg shadow">
|
||||
<div className="text-4xl mb-4">🌿</div>
|
||||
<p className="text-gray-500">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gridCols = {
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
|
||||
};
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{listings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} variant="compact" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid ${gridCols[columns]} gap-6`}>
|
||||
{listings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListingGrid;
|
||||
144
components/marketplace/OfferForm.tsx
Normal file
144
components/marketplace/OfferForm.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface OfferFormProps {
|
||||
listingId: string;
|
||||
askingPrice: number;
|
||||
currency?: string;
|
||||
onSubmit: (amount: number, message: string) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function OfferForm({
|
||||
listingId,
|
||||
askingPrice,
|
||||
currency = 'USD',
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: OfferFormProps) {
|
||||
const [amount, setAmount] = useState(askingPrice.toString());
|
||||
const [message, setMessage] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const offerAmount = parseFloat(amount);
|
||||
|
||||
if (isNaN(offerAmount) || offerAmount <= 0) {
|
||||
setError('Please enter a valid offer amount');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(offerAmount, message);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit offer');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const suggestedOffers = [
|
||||
{ label: 'Full Price', value: askingPrice },
|
||||
{ label: '10% Off', value: Math.round(askingPrice * 0.9 * 100) / 100 },
|
||||
{ label: '15% Off', value: Math.round(askingPrice * 0.85 * 100) / 100 },
|
||||
];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested Offers */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quick Select
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{suggestedOffers.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion.label}
|
||||
type="button"
|
||||
onClick={() => setAmount(suggestion.value.toString())}
|
||||
className={`px-3 py-2 rounded-lg text-sm transition ${
|
||||
parseFloat(amount) === suggestion.value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{suggestion.label}
|
||||
<br />
|
||||
<span className="font-semibold">${suggestion.value.toFixed(2)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Amount */}
|
||||
<div>
|
||||
<label htmlFor="offerAmount" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Your Offer ({currency})
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<input
|
||||
type="number"
|
||||
id="offerAmount"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
required
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
className="w-full pl-8 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Asking price: ${askingPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="offerMessage" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Message to Seller (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="offerMessage"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Introduce yourself or ask a question..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex-1 py-3 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 transition disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Offer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfferForm;
|
||||
163
components/marketplace/OfferList.tsx
Normal file
163
components/marketplace/OfferList.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
interface Offer {
|
||||
id: string;
|
||||
buyerId: string;
|
||||
buyerName?: string;
|
||||
amount: number;
|
||||
message?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface OfferListProps {
|
||||
offers: Offer[];
|
||||
isSellerView?: boolean;
|
||||
onAccept?: (offerId: string) => void;
|
||||
onReject?: (offerId: string) => void;
|
||||
onWithdraw?: (offerId: string) => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
rejected: 'bg-red-100 text-red-800',
|
||||
withdrawn: 'bg-gray-100 text-gray-800',
|
||||
expired: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
accepted: 'Accepted',
|
||||
rejected: 'Rejected',
|
||||
withdrawn: 'Withdrawn',
|
||||
expired: 'Expired',
|
||||
};
|
||||
|
||||
export function OfferList({
|
||||
offers,
|
||||
isSellerView = false,
|
||||
onAccept,
|
||||
onReject,
|
||||
onWithdraw,
|
||||
}: OfferListProps) {
|
||||
if (offers.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 bg-gray-50 rounded-lg">
|
||||
<div className="text-3xl mb-2">📭</div>
|
||||
<p className="text-gray-500">No offers yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{offers.map((offer) => (
|
||||
<OfferItem
|
||||
key={offer.id}
|
||||
offer={offer}
|
||||
isSellerView={isSellerView}
|
||||
onAccept={onAccept}
|
||||
onReject={onReject}
|
||||
onWithdraw={onWithdraw}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OfferItemProps {
|
||||
offer: Offer;
|
||||
isSellerView: boolean;
|
||||
onAccept?: (offerId: string) => void;
|
||||
onReject?: (offerId: string) => void;
|
||||
onWithdraw?: (offerId: string) => void;
|
||||
}
|
||||
|
||||
function OfferItem({
|
||||
offer,
|
||||
isSellerView,
|
||||
onAccept,
|
||||
onReject,
|
||||
onWithdraw,
|
||||
}: OfferItemProps) {
|
||||
const isPending = offer.status === 'pending';
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
{isSellerView && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span>👤</span>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">
|
||||
{offer.buyerName || 'Anonymous'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
${offer.amount.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{new Date(offer.createdAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
statusColors[offer.status]
|
||||
}`}
|
||||
>
|
||||
{statusLabels[offer.status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{offer.message && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-gray-700 text-sm">
|
||||
"{offer.message}"
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPending && (
|
||||
<div className="flex gap-2 mt-4 pt-4 border-t">
|
||||
{isSellerView ? (
|
||||
<>
|
||||
{onAccept && (
|
||||
<button
|
||||
onClick={() => onAccept(offer.id)}
|
||||
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition font-medium"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
)}
|
||||
{onReject && (
|
||||
<button
|
||||
onClick={() => onReject(offer.id)}
|
||||
className="flex-1 py-2 border border-red-300 text-red-600 rounded-lg hover:bg-red-50 transition font-medium"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
onWithdraw && (
|
||||
<button
|
||||
onClick={() => onWithdraw(offer.id)}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition"
|
||||
>
|
||||
Withdraw Offer
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OfferList;
|
||||
96
components/marketplace/PriceDisplay.tsx
Normal file
96
components/marketplace/PriceDisplay.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
interface PriceDisplayProps {
|
||||
price: number;
|
||||
currency?: string;
|
||||
originalPrice?: number;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
showCurrency?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-lg',
|
||||
lg: 'text-2xl',
|
||||
xl: 'text-4xl',
|
||||
};
|
||||
|
||||
export function PriceDisplay({
|
||||
price,
|
||||
currency = 'USD',
|
||||
originalPrice,
|
||||
size = 'md',
|
||||
showCurrency = false,
|
||||
}: PriceDisplayProps) {
|
||||
const hasDiscount = originalPrice && originalPrice > price;
|
||||
const discountPercentage = hasDiscount
|
||||
? Math.round((1 - price / originalPrice) * 100)
|
||||
: 0;
|
||||
|
||||
const formatPrice = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={`font-bold text-green-600 ${sizeClasses[size]}`}>
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
|
||||
{showCurrency && (
|
||||
<span className="text-gray-500 text-sm">{currency}</span>
|
||||
)}
|
||||
|
||||
{hasDiscount && (
|
||||
<>
|
||||
<span className="text-gray-400 line-through text-sm">
|
||||
{formatPrice(originalPrice)}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-red-100 text-red-700 text-xs rounded-full font-medium">
|
||||
{discountPercentage}% OFF
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PriceRangeDisplayProps {
|
||||
minPrice: number;
|
||||
maxPrice: number;
|
||||
currency?: string;
|
||||
}
|
||||
|
||||
export function PriceRangeDisplay({
|
||||
minPrice,
|
||||
maxPrice,
|
||||
currency = 'USD',
|
||||
}: PriceRangeDisplayProps) {
|
||||
const formatPrice = (value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
if (minPrice === maxPrice) {
|
||||
return (
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatPrice(minPrice)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="font-semibold text-green-600">
|
||||
{formatPrice(minPrice)} - {formatPrice(maxPrice)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default PriceDisplay;
|
||||
219
components/marketplace/SearchFilters.tsx
Normal file
219
components/marketplace/SearchFilters.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
interface SearchFiltersProps {
|
||||
initialValues?: {
|
||||
query?: string;
|
||||
category?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
sortBy?: string;
|
||||
};
|
||||
onApply: (filters: {
|
||||
query?: string;
|
||||
category?: string;
|
||||
minPrice?: string;
|
||||
maxPrice?: string;
|
||||
sortBy?: string;
|
||||
}) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
seeds: 'Seeds',
|
||||
seedlings: 'Seedlings',
|
||||
mature_plants: 'Mature Plants',
|
||||
cuttings: 'Cuttings',
|
||||
produce: 'Produce',
|
||||
supplies: 'Supplies',
|
||||
};
|
||||
|
||||
const categoryIcons: Record<string, string> = {
|
||||
seeds: '🌰',
|
||||
seedlings: '🌱',
|
||||
mature_plants: '🪴',
|
||||
cuttings: '✂️',
|
||||
produce: '🥬',
|
||||
supplies: '🧰',
|
||||
};
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'date_desc', label: 'Newest First' },
|
||||
{ value: 'date_asc', label: 'Oldest First' },
|
||||
{ value: 'price_asc', label: 'Price: Low to High' },
|
||||
{ value: 'price_desc', label: 'Price: High to Low' },
|
||||
{ value: 'relevance', label: 'Most Popular' },
|
||||
];
|
||||
|
||||
export function SearchFilters({
|
||||
initialValues = {},
|
||||
onApply,
|
||||
onClear,
|
||||
}: SearchFiltersProps) {
|
||||
const [query, setQuery] = useState(initialValues.query || '');
|
||||
const [category, setCategory] = useState(initialValues.category || '');
|
||||
const [minPrice, setMinPrice] = useState(initialValues.minPrice || '');
|
||||
const [maxPrice, setMaxPrice] = useState(initialValues.maxPrice || '');
|
||||
const [sortBy, setSortBy] = useState(initialValues.sortBy || 'date_desc');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const handleApply = () => {
|
||||
onApply({
|
||||
query: query || undefined,
|
||||
category: category || undefined,
|
||||
minPrice: minPrice || undefined,
|
||||
maxPrice: maxPrice || undefined,
|
||||
sortBy: sortBy || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('');
|
||||
setCategory('');
|
||||
setMinPrice('');
|
||||
setMaxPrice('');
|
||||
setSortBy('date_desc');
|
||||
onClear();
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
handleApply();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
{/* Search Bar */}
|
||||
<form onSubmit={handleSearch} className="mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search listings..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Toggle Filters */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-800 mb-2"
|
||||
>
|
||||
<span>{isExpanded ? '▼' : '▶'}</span>
|
||||
<span>Advanced Filters</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Filters */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Category
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setCategory('')}
|
||||
className={`px-3 py-1 rounded-full text-sm transition ${
|
||||
!category
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{Object.entries(categoryLabels).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setCategory(value)}
|
||||
className={`px-3 py-1 rounded-full text-sm transition ${
|
||||
category === value
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{categoryIcons[value]} {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Range */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Price Range
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
placeholder="Min"
|
||||
min="0"
|
||||
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-400">-</span>
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">$</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
placeholder="Max"
|
||||
min="0"
|
||||
className="w-full pl-7 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent"
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="flex-1 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
className="flex-1 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFilters;
|
||||
8
components/marketplace/index.ts
Normal file
8
components/marketplace/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Marketplace Components Index
|
||||
export { ListingCard } from './ListingCard';
|
||||
export { ListingGrid } from './ListingGrid';
|
||||
export { ListingForm } from './ListingForm';
|
||||
export { OfferForm } from './OfferForm';
|
||||
export { OfferList } from './OfferList';
|
||||
export { SearchFilters } from './SearchFilters';
|
||||
export { PriceDisplay, PriceRangeDisplay } from './PriceDisplay';
|
||||
92
components/mobile/BottomNav.tsx
Normal file
92
components/mobile/BottomNav.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: '/m',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Home',
|
||||
},
|
||||
{
|
||||
href: '/m/scan',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Scan',
|
||||
},
|
||||
{
|
||||
href: '/m/quick-add',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Add',
|
||||
},
|
||||
{
|
||||
href: '/plants/explore',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Explore',
|
||||
},
|
||||
{
|
||||
href: '/m/profile',
|
||||
icon: (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
),
|
||||
label: 'Profile',
|
||||
},
|
||||
];
|
||||
|
||||
export function BottomNav() {
|
||||
const router = useRouter();
|
||||
const pathname = router.pathname;
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 pb-safe md:hidden">
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
'flex flex-col items-center justify-center w-full h-full space-y-1 transition-colors',
|
||||
{
|
||||
'text-green-600': isActive,
|
||||
'text-gray-500 hover:text-gray-700': !isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="text-xs font-medium">{item.label}</span>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomNav;
|
||||
183
components/mobile/InstallPrompt.tsx
Normal file
183
components/mobile/InstallPrompt.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import * as React from 'react';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[];
|
||||
readonly userChoice: Promise<{
|
||||
outcome: 'accepted' | 'dismissed';
|
||||
platform: string;
|
||||
}>;
|
||||
prompt(): Promise<void>;
|
||||
}
|
||||
|
||||
export function InstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = React.useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showPrompt, setShowPrompt] = React.useState(false);
|
||||
const [isIOS, setIsIOS] = React.useState(false);
|
||||
const [isInstalled, setIsInstalled] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if iOS
|
||||
const ua = window.navigator.userAgent;
|
||||
const isIOSDevice = /iPad|iPhone|iPod/.test(ua) && !(window as any).MSStream;
|
||||
setIsIOS(isIOSDevice);
|
||||
|
||||
// Check if user has dismissed the prompt recently
|
||||
const dismissedAt = localStorage.getItem('pwa-prompt-dismissed');
|
||||
if (dismissedAt) {
|
||||
const dismissedTime = new Date(dismissedAt).getTime();
|
||||
const now = Date.now();
|
||||
const dayInMs = 24 * 60 * 60 * 1000;
|
||||
if (now - dismissedTime < 7 * dayInMs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-iOS devices, wait for the beforeinstallprompt event
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowPrompt(true);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
|
||||
// For iOS, show prompt after a delay if not installed
|
||||
if (isIOSDevice && !navigator.standalone) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowPrompt(true);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
setShowPrompt(false);
|
||||
setIsInstalled(true);
|
||||
}
|
||||
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false);
|
||||
localStorage.setItem('pwa-prompt-dismissed', new Date().toISOString());
|
||||
};
|
||||
|
||||
if (!showPrompt || isInstalled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-20 left-4 right-4 z-50 md:left-auto md:right-4 md:max-w-sm animate-slide-up">
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
{/* App Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-12 h-12 bg-green-600 rounded-xl flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-7 w-7 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-gray-900">Install LocalGreenChain</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{isIOS
|
||||
? 'Tap the share button and select "Add to Home Screen"'
|
||||
: 'Install our app for a better experience with offline support'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 p-1 rounded-full hover:bg-gray-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* iOS Instructions */}
|
||||
{isIOS && (
|
||||
<div className="mt-3 flex items-center space-x-2 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Then tap "Add to Home Screen"</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Button (non-iOS) */}
|
||||
{!isIOS && deferredPrompt && (
|
||||
<div className="mt-3 flex space-x-3">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallPrompt;
|
||||
101
components/mobile/MobileHeader.tsx
Normal file
101
components/mobile/MobileHeader.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import * as React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface MobileHeaderProps {
|
||||
title?: string;
|
||||
showBack?: boolean;
|
||||
rightAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MobileHeader({ title, showBack = false, rightAction }: MobileHeaderProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push('/m');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-white border-b border-gray-200 pt-safe md:hidden">
|
||||
<div className="flex items-center justify-between h-14 px-4">
|
||||
{/* Left side */}
|
||||
<div className="flex items-center w-20">
|
||||
{showBack ? (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center justify-center w-10 h-10 -ml-2 rounded-full hover:bg-gray-100 active:bg-gray-200"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 text-gray-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/m">
|
||||
<a className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-green-600 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center - Title */}
|
||||
<div className="flex-1 text-center">
|
||||
<h1 className="text-lg font-semibold text-gray-900 truncate">{title || 'LocalGreenChain'}</h1>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center justify-end w-20">
|
||||
{rightAction || (
|
||||
<button
|
||||
className="flex items-center justify-center w-10 h-10 -mr-2 rounded-full hover:bg-gray-100 active:bg-gray-200"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 text-gray-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileHeader;
|
||||
138
components/mobile/PullToRefresh.tsx
Normal file
138
components/mobile/PullToRefresh.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface PullToRefreshProps {
|
||||
onRefresh: () => Promise<void>;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [startY, setStartY] = React.useState(0);
|
||||
const [pullDistance, setPullDistance] = React.useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [isPulling, setIsPulling] = React.useState(false);
|
||||
|
||||
const threshold = 80;
|
||||
const maxPull = 120;
|
||||
const resistance = 2.5;
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
// Only start if scrolled to top
|
||||
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
||||
setStartY(e.touches[0].clientY);
|
||||
setIsPulling(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (!isPulling || isRefreshing) return;
|
||||
|
||||
const currentY = e.touches[0].clientY;
|
||||
const diff = (currentY - startY) / resistance;
|
||||
|
||||
if (diff > 0) {
|
||||
const distance = Math.min(maxPull, diff);
|
||||
setPullDistance(distance);
|
||||
|
||||
// Prevent default scroll when pulling
|
||||
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!isPulling || isRefreshing) return;
|
||||
|
||||
setIsPulling(false);
|
||||
|
||||
if (pullDistance >= threshold) {
|
||||
setIsRefreshing(true);
|
||||
setPullDistance(60); // Keep indicator visible during refresh
|
||||
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
setPullDistance(0);
|
||||
}
|
||||
} else {
|
||||
setPullDistance(0);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = Math.min(1, pullDistance / threshold);
|
||||
const rotation = pullDistance * 3;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames('relative overflow-auto', className)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Pull indicator */}
|
||||
<div
|
||||
className="absolute left-1/2 transform -translate-x-1/2 z-10 transition-opacity"
|
||||
style={{
|
||||
top: pullDistance - 40,
|
||||
opacity: pullDistance > 10 ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center',
|
||||
{ 'animate-spin': isRefreshing }
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={classNames('h-6 w-6 text-green-600 transition-transform', {
|
||||
'animate-spin': isRefreshing,
|
||||
})}
|
||||
style={{ transform: isRefreshing ? undefined : `rotate(${rotation}deg)` }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pull text */}
|
||||
{pullDistance > 10 && !isRefreshing && (
|
||||
<div
|
||||
className="absolute left-1/2 transform -translate-x-1/2 text-sm text-gray-500 transition-opacity z-10"
|
||||
style={{
|
||||
top: pullDistance + 5,
|
||||
opacity: progress,
|
||||
}}
|
||||
>
|
||||
{pullDistance >= threshold ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="transition-transform"
|
||||
style={{
|
||||
transform: `translateY(${pullDistance}px)`,
|
||||
transitionDuration: isPulling ? '0ms' : '200ms',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PullToRefresh;
|
||||
196
components/mobile/QRScanner.tsx
Normal file
196
components/mobile/QRScanner.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface QRScannerProps {
|
||||
onScan: (result: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QRScanner({ onScan, onError, onClose, className }: QRScannerProps) {
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [hasCamera, setHasCamera] = React.useState(true);
|
||||
const [cameraError, setCameraError] = React.useState<string | null>(null);
|
||||
const streamRef = React.useRef<MediaStream | null>(null);
|
||||
|
||||
const startCamera = React.useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
setIsScanning(true);
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
setHasCamera(false);
|
||||
setCameraError(error.message);
|
||||
onError?.(error);
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
const stopCamera = React.useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setIsScanning(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
startCamera();
|
||||
return () => stopCamera();
|
||||
}, [startCamera, stopCamera]);
|
||||
|
||||
// Simple QR detection simulation (in production, use a library like jsQR)
|
||||
React.useEffect(() => {
|
||||
if (!isScanning) return;
|
||||
|
||||
const scanInterval = setInterval(() => {
|
||||
if (videoRef.current && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
const video = videoRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// In production, use jsQR library here:
|
||||
// const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
// const code = jsQR(imageData.data, imageData.width, imageData.height);
|
||||
// if (code) {
|
||||
// stopCamera();
|
||||
// onScan(code.data);
|
||||
// }
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearInterval(scanInterval);
|
||||
}, [isScanning, onScan, stopCamera]);
|
||||
|
||||
// Demo function to simulate a scan
|
||||
const simulateScan = () => {
|
||||
stopCamera();
|
||||
onScan('plant:abc123-tomato-heirloom');
|
||||
};
|
||||
|
||||
if (!hasCamera) {
|
||||
return (
|
||||
<div className={classNames('flex flex-col items-center justify-center p-8 bg-gray-100 rounded-lg', className)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-16 w-16 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<p className="text-gray-600 text-center mb-2">Camera access denied</p>
|
||||
<p className="text-sm text-gray-500 text-center">{cameraError}</p>
|
||||
<button
|
||||
onClick={startCamera}
|
||||
className="mt-4 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('relative overflow-hidden rounded-lg bg-black', className)}>
|
||||
{/* Video feed */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-cover"
|
||||
playsInline
|
||||
muted
|
||||
/>
|
||||
|
||||
{/* Hidden canvas for image processing */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{/* Scanning overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{/* Darkened corners */}
|
||||
<div className="absolute inset-0 bg-black/50" />
|
||||
|
||||
{/* Transparent scanning area */}
|
||||
<div className="relative w-64 h-64">
|
||||
{/* Cut out the scanning area */}
|
||||
<div className="absolute inset-0 border-2 border-white rounded-lg" />
|
||||
|
||||
{/* Corner markers */}
|
||||
<div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-green-500 rounded-tl-lg" />
|
||||
<div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-green-500 rounded-tr-lg" />
|
||||
<div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-green-500 rounded-bl-lg" />
|
||||
<div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-green-500 rounded-br-lg" />
|
||||
|
||||
{/* Scanning line animation */}
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5 bg-green-500 animate-scan-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="absolute bottom-20 left-0 right-0 text-center">
|
||||
<p className="text-white text-sm bg-black/50 inline-block px-4 py-2 rounded-full">
|
||||
Point camera at QR code
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Demo scan button (remove in production) */}
|
||||
<button
|
||||
onClick={simulateScan}
|
||||
className="absolute bottom-4 left-1/2 transform -translate-x-1/2 px-6 py-2 bg-green-600 text-white rounded-full text-sm font-medium shadow-lg"
|
||||
>
|
||||
Demo: Simulate Scan
|
||||
</button>
|
||||
|
||||
{/* Close button */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={() => {
|
||||
stopCamera();
|
||||
onClose();
|
||||
}}
|
||||
className="absolute top-4 right-4 w-10 h-10 bg-black/50 rounded-full flex items-center justify-center"
|
||||
aria-label="Close scanner"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default QRScanner;
|
||||
131
components/mobile/SwipeableCard.tsx
Normal file
131
components/mobile/SwipeableCard.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import * as React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface SwipeableCardProps {
|
||||
children: React.ReactNode;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
leftAction?: React.ReactNode;
|
||||
rightAction?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SwipeableCard({
|
||||
children,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
leftAction,
|
||||
rightAction,
|
||||
className,
|
||||
}: SwipeableCardProps) {
|
||||
const cardRef = React.useRef<HTMLDivElement>(null);
|
||||
const [startX, setStartX] = React.useState(0);
|
||||
const [currentX, setCurrentX] = React.useState(0);
|
||||
const [isSwiping, setIsSwiping] = React.useState(false);
|
||||
|
||||
const threshold = 100;
|
||||
const maxSwipe = 150;
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
setStartX(e.touches[0].clientX);
|
||||
setIsSwiping(true);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (!isSwiping) return;
|
||||
|
||||
const diff = e.touches[0].clientX - startX;
|
||||
const clampedDiff = Math.max(-maxSwipe, Math.min(maxSwipe, diff));
|
||||
|
||||
// Only allow swiping in directions that have actions
|
||||
if (diff > 0 && !onSwipeRight) return;
|
||||
if (diff < 0 && !onSwipeLeft) return;
|
||||
|
||||
setCurrentX(clampedDiff);
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsSwiping(false);
|
||||
|
||||
if (currentX > threshold && onSwipeRight) {
|
||||
onSwipeRight();
|
||||
} else if (currentX < -threshold && onSwipeLeft) {
|
||||
onSwipeLeft();
|
||||
}
|
||||
|
||||
setCurrentX(0);
|
||||
};
|
||||
|
||||
const swipeProgress = Math.abs(currentX) / threshold;
|
||||
const direction = currentX > 0 ? 'right' : 'left';
|
||||
|
||||
return (
|
||||
<div className={classNames('relative overflow-hidden rounded-lg', className)}>
|
||||
{/* Left action background */}
|
||||
{rightAction && (
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute inset-y-0 left-0 flex items-center justify-start pl-4 bg-green-500 transition-opacity',
|
||||
{
|
||||
'opacity-100': currentX > 0,
|
||||
'opacity-0': currentX <= 0,
|
||||
}
|
||||
)}
|
||||
style={{ width: Math.abs(currentX) }}
|
||||
>
|
||||
{rightAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right action background */}
|
||||
{leftAction && (
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute inset-y-0 right-0 flex items-center justify-end pr-4 bg-red-500 transition-opacity',
|
||||
{
|
||||
'opacity-100': currentX < 0,
|
||||
'opacity-0': currentX >= 0,
|
||||
}
|
||||
)}
|
||||
style={{ width: Math.abs(currentX) }}
|
||||
>
|
||||
{leftAction}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main card content */}
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="relative bg-white transition-transform touch-pan-y"
|
||||
style={{
|
||||
transform: `translateX(${currentX}px)`,
|
||||
transitionDuration: isSwiping ? '0ms' : '200ms',
|
||||
}}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Swipe indicator */}
|
||||
{isSwiping && Math.abs(currentX) > 20 && (
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute top-1/2 transform -translate-y-1/2 text-white text-sm font-medium',
|
||||
{
|
||||
'left-4': direction === 'right',
|
||||
'right-4': direction === 'left',
|
||||
}
|
||||
)}
|
||||
style={{ opacity: swipeProgress }}
|
||||
>
|
||||
{direction === 'right' && onSwipeRight && 'Release to confirm'}
|
||||
{direction === 'left' && onSwipeLeft && 'Release to delete'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SwipeableCard;
|
||||
6
components/mobile/index.ts
Normal file
6
components/mobile/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { BottomNav } from './BottomNav';
|
||||
export { MobileHeader } from './MobileHeader';
|
||||
export { InstallPrompt } from './InstallPrompt';
|
||||
export { SwipeableCard } from './SwipeableCard';
|
||||
export { PullToRefresh } from './PullToRefresh';
|
||||
export { QRScanner } from './QRScanner';
|
||||
127
components/notifications/NotificationBell.tsx
Normal file
127
components/notifications/NotificationBell.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* NotificationBell Component
|
||||
* Header bell icon with unread badge and dropdown
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { NotificationList } from './NotificationList';
|
||||
|
||||
interface NotificationBellProps {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function NotificationBell({ userId = 'demo-user' }: NotificationBellProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
|
||||
// Poll for new notifications every 30 seconds
|
||||
const interval = setInterval(fetchUnreadCount, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
async function fetchUnreadCount() {
|
||||
try {
|
||||
const response = await fetch(`/api/notifications?userId=${userId}&unreadOnly=true&limit=1`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setUnreadCount(data.data.unreadCount);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unread count:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotificationRead() {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
|
||||
function handleAllRead() {
|
||||
setUnreadCount(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 text-gray-600 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-green-500 rounded-full transition-colors"
|
||||
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{!isLoading && unreadCount > 0 && (
|
||||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-96 max-h-[80vh] bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
||||
<div className="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleAllRead}
|
||||
className="text-sm text-green-600 hover:text-green-700"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-96">
|
||||
<NotificationList
|
||||
userId={userId}
|
||||
onNotificationRead={handleNotificationRead}
|
||||
onAllRead={handleAllRead}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 border-t border-gray-100 bg-gray-50">
|
||||
<a
|
||||
href="/notifications"
|
||||
className="block text-center text-sm text-green-600 hover:text-green-700"
|
||||
>
|
||||
View all notifications
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
components/notifications/NotificationItem.tsx
Normal file
156
components/notifications/NotificationItem.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* NotificationItem Component
|
||||
* Single notification display with actions
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
onMarkAsRead: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, { icon: string; bgColor: string }> = {
|
||||
welcome: { icon: '👋', bgColor: 'bg-blue-100' },
|
||||
plant_registered: { icon: '🌱', bgColor: 'bg-green-100' },
|
||||
plant_reminder: { icon: '🌿', bgColor: 'bg-green-100' },
|
||||
transport_alert: { icon: '🚚', bgColor: 'bg-yellow-100' },
|
||||
farm_alert: { icon: '🏭', bgColor: 'bg-orange-100' },
|
||||
harvest_ready: { icon: '🎉', bgColor: 'bg-green-100' },
|
||||
demand_match: { icon: '🤝', bgColor: 'bg-purple-100' },
|
||||
weekly_digest: { icon: '📊', bgColor: 'bg-blue-100' },
|
||||
system_alert: { icon: '⚙️', bgColor: 'bg-gray-100' }
|
||||
};
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
onMarkAsRead,
|
||||
onDelete,
|
||||
compact = false
|
||||
}: NotificationItemProps) {
|
||||
const { icon, bgColor } = typeIcons[notification.type] || typeIcons.system_alert;
|
||||
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
if (seconds < 60) return 'Just now';
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (!notification.read) {
|
||||
onMarkAsRead(notification.id);
|
||||
}
|
||||
if (notification.actionUrl) {
|
||||
window.location.href = notification.actionUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative group ${compact ? 'p-3' : 'p-4'} hover:bg-gray-50 transition-colors ${
|
||||
!notification.read ? 'bg-green-50/30' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-start cursor-pointer"
|
||||
onClick={handleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyPress={e => e.key === 'Enter' && handleClick()}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`flex-shrink-0 ${compact ? 'w-8 h-8' : 'w-10 h-10'} ${bgColor} rounded-full flex items-center justify-center`}
|
||||
>
|
||||
<span className={compact ? 'text-sm' : 'text-lg'}>{icon}</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`flex-1 ${compact ? 'ml-3' : 'ml-4'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`font-medium text-gray-900 ${compact ? 'text-sm' : ''} ${
|
||||
!notification.read ? 'font-semibold' : ''
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className={`text-gray-600 mt-0.5 ${compact ? 'text-xs line-clamp-2' : 'text-sm'}`}>
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Unread indicator */}
|
||||
{!notification.read && (
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
<span className={`text-gray-400 ${compact ? 'text-xs' : 'text-xs'}`}>
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</span>
|
||||
|
||||
{notification.actionUrl && (
|
||||
<span className={`ml-2 text-green-600 ${compact ? 'text-xs' : 'text-xs'}`}>
|
||||
View details →
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions (visible on hover) */}
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity flex space-x-1">
|
||||
{!notification.read && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onMarkAsRead(notification.id);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded"
|
||||
title="Mark as read"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onDelete(notification.id);
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
components/notifications/NotificationList.tsx
Normal file
229
components/notifications/NotificationList.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* NotificationList Component
|
||||
* Displays a list of notifications with infinite scroll
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { NotificationItem } from './NotificationItem';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface NotificationListProps {
|
||||
userId?: string;
|
||||
onNotificationRead?: () => void;
|
||||
onAllRead?: () => void;
|
||||
compact?: boolean;
|
||||
showFilters?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationList({
|
||||
userId = 'demo-user',
|
||||
onNotificationRead,
|
||||
onAllRead,
|
||||
compact = false,
|
||||
showFilters = false
|
||||
}: NotificationListProps) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const limit = compact ? 5 : 20;
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications(true);
|
||||
}, [userId, filter]);
|
||||
|
||||
async function fetchNotifications(reset = false) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const currentOffset = reset ? 0 : offset;
|
||||
const unreadOnly = filter === 'unread';
|
||||
|
||||
const response = await fetch(
|
||||
`/api/notifications?userId=${userId}&limit=${limit}&offset=${currentOffset}&unreadOnly=${unreadOnly}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
if (reset) {
|
||||
setNotifications(data.data.notifications);
|
||||
} else {
|
||||
setNotifications(prev => [...prev, ...data.data.notifications]);
|
||||
}
|
||||
setHasMore(data.data.pagination.hasMore);
|
||||
setOffset(currentOffset + limit);
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAsRead(notificationId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ read: true, userId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications(prev =>
|
||||
prev.map(n => (n.id === notificationId ? { ...n, read: true } : n))
|
||||
);
|
||||
onNotificationRead?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllAsRead() {
|
||||
try {
|
||||
const response = await fetch('/api/notifications/read-all', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })));
|
||||
onAllRead?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark all as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(notificationId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/notifications/${notificationId}?userId=${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNotifications(prev => prev.filter(n => n.id !== notificationId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete notification:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-4 text-center text-red-600">
|
||||
<p>Failed to load notifications</p>
|
||||
<button
|
||||
onClick={() => fetchNotifications(true)}
|
||||
className="mt-2 text-sm text-green-600 hover:text-green-700"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={compact ? '' : 'max-w-2xl mx-auto'}>
|
||||
{showFilters && (
|
||||
<div className="flex items-center justify-between mb-4 p-4 bg-white rounded-lg border">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
filter === 'all'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('unread')}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
filter === 'unread'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
Unread
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="text-sm text-green-600 hover:text-green-700"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && notifications.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
|
||||
<p className="mt-2 text-gray-500">Loading notifications...</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto mb-4 text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<p>No notifications yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{notifications.map(notification => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onMarkAsRead={handleMarkAsRead}
|
||||
onDelete={handleDelete}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && !isLoading && (
|
||||
<div className="p-4 text-center">
|
||||
<button
|
||||
onClick={() => fetchNotifications(false)}
|
||||
className="text-sm text-green-600 hover:text-green-700"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && notifications.length > 0 && (
|
||||
<div className="p-4 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-4 w-4 border-2 border-gray-200 border-t-green-500"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
components/notifications/PreferencesForm.tsx
Normal file
285
components/notifications/PreferencesForm.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/**
|
||||
* PreferencesForm Component
|
||||
* User notification preferences management
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface NotificationPreferences {
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
inApp: boolean;
|
||||
plantReminders: boolean;
|
||||
transportAlerts: boolean;
|
||||
farmAlerts: boolean;
|
||||
harvestAlerts: boolean;
|
||||
demandMatches: boolean;
|
||||
weeklyDigest: boolean;
|
||||
quietHoursStart?: string;
|
||||
quietHoursEnd?: string;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
interface PreferencesFormProps {
|
||||
userId?: string;
|
||||
onSave?: (preferences: NotificationPreferences) => void;
|
||||
}
|
||||
|
||||
export function PreferencesForm({ userId = 'demo-user', onSave }: PreferencesFormProps) {
|
||||
const [preferences, setPreferences] = useState<NotificationPreferences>({
|
||||
email: true,
|
||||
push: true,
|
||||
inApp: true,
|
||||
plantReminders: true,
|
||||
transportAlerts: true,
|
||||
farmAlerts: true,
|
||||
harvestAlerts: true,
|
||||
demandMatches: true,
|
||||
weeklyDigest: true
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreferences();
|
||||
}, [userId]);
|
||||
|
||||
async function fetchPreferences() {
|
||||
try {
|
||||
const response = await fetch(`/api/notifications/preferences?userId=${userId}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPreferences(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch preferences:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/notifications/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...preferences, userId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: 'Preferences saved successfully!' });
|
||||
onSave?.(data.data);
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save preferences' });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to save preferences' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggle(key: keyof NotificationPreferences) {
|
||||
setPreferences(prev => ({
|
||||
...prev,
|
||||
[key]: !prev[key]
|
||||
}));
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-200 border-t-green-500"></div>
|
||||
<p className="mt-2 text-gray-500">Loading preferences...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg ${
|
||||
message.type === 'success' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notification Channels */}
|
||||
<div className="bg-white p-6 rounded-lg border">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Channels</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">Choose how you want to receive notifications</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Email notifications"
|
||||
description="Receive notifications via email"
|
||||
enabled={preferences.email}
|
||||
onChange={() => handleToggle('email')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Push notifications"
|
||||
description="Receive browser push notifications"
|
||||
enabled={preferences.push}
|
||||
onChange={() => handleToggle('push')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="In-app notifications"
|
||||
description="See notifications in the app"
|
||||
enabled={preferences.inApp}
|
||||
onChange={() => handleToggle('inApp')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Types */}
|
||||
<div className="bg-white p-6 rounded-lg border">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Notification Types</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">Choose which types of notifications you want to receive</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<ToggleRow
|
||||
label="Plant reminders"
|
||||
description="Reminders for watering, fertilizing, and plant care"
|
||||
enabled={preferences.plantReminders}
|
||||
onChange={() => handleToggle('plantReminders')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Transport alerts"
|
||||
description="Updates about plant transport and logistics"
|
||||
enabled={preferences.transportAlerts}
|
||||
onChange={() => handleToggle('transportAlerts')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Farm alerts"
|
||||
description="Alerts about vertical farm conditions and issues"
|
||||
enabled={preferences.farmAlerts}
|
||||
onChange={() => handleToggle('farmAlerts')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Harvest alerts"
|
||||
description="Notifications when crops are ready for harvest"
|
||||
enabled={preferences.harvestAlerts}
|
||||
onChange={() => handleToggle('harvestAlerts')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Demand matches"
|
||||
description="Alerts when your supply matches consumer demand"
|
||||
enabled={preferences.demandMatches}
|
||||
onChange={() => handleToggle('demandMatches')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Weekly digest"
|
||||
description="Weekly summary of your activity and insights"
|
||||
enabled={preferences.weeklyDigest}
|
||||
onChange={() => handleToggle('weeklyDigest')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quiet Hours */}
|
||||
<div className="bg-white p-6 rounded-lg border">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quiet Hours</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">Set times when you don't want to receive notifications</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.quietHoursStart || ''}
|
||||
onChange={e =>
|
||||
setPreferences(prev => ({ ...prev, quietHoursStart: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">End time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={preferences.quietHoursEnd || ''}
|
||||
onChange={e =>
|
||||
setPreferences(prev => ({ ...prev, quietHoursEnd: e.target.value }))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
|
||||
<select
|
||||
value={preferences.timezone || ''}
|
||||
onChange={e => setPreferences(prev => ({ ...prev, timezone: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
<option value="">Select timezone</option>
|
||||
<option value="America/New_York">Eastern Time</option>
|
||||
<option value="America/Chicago">Central Time</option>
|
||||
<option value="America/Denver">Mountain Time</option>
|
||||
<option value="America/Los_Angeles">Pacific Time</option>
|
||||
<option value="Europe/London">London</option>
|
||||
<option value="Europe/Paris">Paris</option>
|
||||
<option value="Asia/Tokyo">Tokyo</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
function ToggleRow({ label, description, enabled, onChange }: ToggleRowProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{label}</p>
|
||||
<p className="text-sm text-gray-500">{description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
enabled ? 'bg-green-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
enabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
components/notifications/index.ts
Normal file
8
components/notifications/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* Notification Components Index
|
||||
*/
|
||||
|
||||
export { NotificationBell } from './NotificationBell';
|
||||
export { NotificationList } from './NotificationList';
|
||||
export { NotificationItem } from './NotificationItem';
|
||||
export { PreferencesForm } from './PreferencesForm';
|
||||
167
components/realtime/ConnectionStatus.tsx
Normal file
167
components/realtime/ConnectionStatus.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Connection Status Indicator Component
|
||||
*
|
||||
* Shows the current WebSocket connection status with visual feedback.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useConnectionStatus } from '../../lib/realtime/useSocket';
|
||||
import type { ConnectionStatus as ConnectionStatusType } from '../../lib/realtime/types';
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
showLabel?: boolean;
|
||||
showLatency?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color classes
|
||||
*/
|
||||
function getStatusColor(status: ConnectionStatusType): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'bg-green-500';
|
||||
case 'connecting':
|
||||
case 'reconnecting':
|
||||
return 'bg-yellow-500 animate-pulse';
|
||||
case 'disconnected':
|
||||
return 'bg-gray-400';
|
||||
case 'error':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status label
|
||||
*/
|
||||
function getStatusLabel(status: ConnectionStatusType): string {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'Connected';
|
||||
case 'connecting':
|
||||
return 'Connecting...';
|
||||
case 'reconnecting':
|
||||
return 'Reconnecting...';
|
||||
case 'disconnected':
|
||||
return 'Disconnected';
|
||||
case 'error':
|
||||
return 'Connection Error';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get size classes
|
||||
*/
|
||||
function getSizeClasses(size: 'sm' | 'md' | 'lg'): { dot: string; text: string } {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return { dot: 'w-2 h-2', text: 'text-xs' };
|
||||
case 'md':
|
||||
return { dot: 'w-3 h-3', text: 'text-sm' };
|
||||
case 'lg':
|
||||
return { dot: 'w-4 h-4', text: 'text-base' };
|
||||
default:
|
||||
return { dot: 'w-3 h-3', text: 'text-sm' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection Status component
|
||||
*/
|
||||
export function ConnectionStatus({
|
||||
showLabel = true,
|
||||
showLatency = false,
|
||||
size = 'md',
|
||||
className,
|
||||
}: ConnectionStatusProps) {
|
||||
const { status, latency } = useConnectionStatus();
|
||||
const sizeClasses = getSizeClasses(size);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'inline-flex items-center gap-2',
|
||||
className
|
||||
)}
|
||||
title={getStatusLabel(status)}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={classNames(
|
||||
'rounded-full',
|
||||
sizeClasses.dot,
|
||||
getStatusColor(status)
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
{showLabel && (
|
||||
<span className={classNames('text-gray-600', sizeClasses.text)}>
|
||||
{getStatusLabel(status)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Latency */}
|
||||
{showLatency && status === 'connected' && latency !== undefined && (
|
||||
<span className={classNames('text-gray-400', sizeClasses.text)}>
|
||||
({latency}ms)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact connection indicator (dot only)
|
||||
*/
|
||||
export function ConnectionDot({ className }: { className?: string }) {
|
||||
const { status } = useConnectionStatus();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
'inline-block w-2 h-2 rounded-full',
|
||||
getStatusColor(status),
|
||||
className
|
||||
)}
|
||||
title={getStatusLabel(status)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection banner for showing reconnection status
|
||||
*/
|
||||
export function ConnectionBanner() {
|
||||
const { status } = useConnectionStatus();
|
||||
|
||||
if (status === 'connected') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bannerClasses = classNames(
|
||||
'fixed top-0 left-0 right-0 py-2 px-4 text-center text-sm font-medium z-50',
|
||||
{
|
||||
'bg-yellow-100 text-yellow-800': status === 'connecting' || status === 'reconnecting',
|
||||
'bg-red-100 text-red-800': status === 'error',
|
||||
'bg-gray-100 text-gray-800': status === 'disconnected',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={bannerClasses}>
|
||||
{status === 'connecting' && 'Connecting to real-time updates...'}
|
||||
{status === 'reconnecting' && 'Connection lost. Reconnecting...'}
|
||||
{status === 'error' && 'Connection error. Please check your network.'}
|
||||
{status === 'disconnected' && 'Disconnected from real-time updates.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConnectionStatus;
|
||||
256
components/realtime/LiveChart.tsx
Normal file
256
components/realtime/LiveChart.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* Live Chart Component
|
||||
*
|
||||
* Displays real-time data as a simple line chart.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSocket } from '../../lib/realtime/useSocket';
|
||||
import type { TransparencyEventType } from '../../lib/realtime/types';
|
||||
|
||||
interface LiveChartProps {
|
||||
eventTypes?: TransparencyEventType[];
|
||||
dataKey?: string;
|
||||
title?: string;
|
||||
color?: string;
|
||||
height?: number;
|
||||
maxDataPoints?: number;
|
||||
showGrid?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple SVG line chart for real-time data
|
||||
*/
|
||||
export function LiveChart({
|
||||
eventTypes = ['system.metric'],
|
||||
dataKey = 'value',
|
||||
title = 'Live Data',
|
||||
color = '#3B82F6',
|
||||
height = 120,
|
||||
maxDataPoints = 30,
|
||||
showGrid = true,
|
||||
className,
|
||||
}: LiveChartProps) {
|
||||
const { events } = useSocket({
|
||||
eventTypes,
|
||||
maxEvents: maxDataPoints,
|
||||
});
|
||||
|
||||
// Extract data points
|
||||
const dataPoints = useMemo(() => {
|
||||
return events
|
||||
.filter((e) => e.data && typeof e.data[dataKey] === 'number')
|
||||
.map((e) => ({
|
||||
value: e.data[dataKey] as number,
|
||||
timestamp: new Date(e.timestamp).getTime(),
|
||||
}))
|
||||
.reverse()
|
||||
.slice(-maxDataPoints);
|
||||
}, [events, dataKey, maxDataPoints]);
|
||||
|
||||
// Calculate chart dimensions
|
||||
const chartWidth = 400;
|
||||
const chartHeight = height - 40;
|
||||
const padding = { top: 10, right: 10, bottom: 20, left: 40 };
|
||||
const innerWidth = chartWidth - padding.left - padding.right;
|
||||
const innerHeight = chartHeight - padding.top - padding.bottom;
|
||||
|
||||
// Calculate scales
|
||||
const { minValue, maxValue, points, pathD } = useMemo(() => {
|
||||
if (dataPoints.length === 0) {
|
||||
return { minValue: 0, maxValue: 100, points: [], pathD: '' };
|
||||
}
|
||||
|
||||
const values = dataPoints.map((d) => d.value);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
|
||||
const pts = dataPoints.map((d, i) => ({
|
||||
x: padding.left + (i / Math.max(1, dataPoints.length - 1)) * innerWidth,
|
||||
y: padding.top + innerHeight - ((d.value - min) / range) * innerHeight,
|
||||
}));
|
||||
|
||||
const d = pts.length > 0
|
||||
? `M ${pts.map((p) => `${p.x},${p.y}`).join(' L ')}`
|
||||
: '';
|
||||
|
||||
return { minValue: min, maxValue: max, points: pts, pathD: d };
|
||||
}, [dataPoints, innerWidth, innerHeight, padding]);
|
||||
|
||||
// Latest value
|
||||
const latestValue = dataPoints.length > 0 ? dataPoints[dataPoints.length - 1].value : null;
|
||||
|
||||
return (
|
||||
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">{title}</h4>
|
||||
{latestValue !== null && (
|
||||
<span className="text-lg font-bold" style={{ color }}>
|
||||
{latestValue.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<svg
|
||||
width="100%"
|
||||
height={chartHeight}
|
||||
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
{/* Grid */}
|
||||
{showGrid && (
|
||||
<g className="text-gray-200">
|
||||
{/* Horizontal grid lines */}
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
|
||||
<line
|
||||
key={`h-${ratio}`}
|
||||
x1={padding.left}
|
||||
y1={padding.top + innerHeight * ratio}
|
||||
x2={padding.left + innerWidth}
|
||||
y2={padding.top + innerHeight * ratio}
|
||||
stroke="currentColor"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
))}
|
||||
{/* Vertical grid lines */}
|
||||
{[0, 0.5, 1].map((ratio) => (
|
||||
<line
|
||||
key={`v-${ratio}`}
|
||||
x1={padding.left + innerWidth * ratio}
|
||||
y1={padding.top}
|
||||
x2={padding.left + innerWidth * ratio}
|
||||
y2={padding.top + innerHeight}
|
||||
stroke="currentColor"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
<g className="text-gray-500 text-xs">
|
||||
<text x={padding.left - 5} y={padding.top + 4} textAnchor="end">
|
||||
{maxValue.toFixed(0)}
|
||||
</text>
|
||||
<text x={padding.left - 5} y={padding.top + innerHeight} textAnchor="end">
|
||||
{minValue.toFixed(0)}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
{/* Line path */}
|
||||
{pathD && (
|
||||
<>
|
||||
{/* Gradient area */}
|
||||
<defs>
|
||||
<linearGradient id="areaGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d={`${pathD} L ${points[points.length - 1]?.x},${padding.top + innerHeight} L ${points[0]?.x},${padding.top + innerHeight} Z`}
|
||||
fill="url(#areaGradient)"
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Data points */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={i === points.length - 1 ? 4 : 2}
|
||||
fill={color}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* No data message */}
|
||||
{dataPoints.length === 0 && (
|
||||
<text
|
||||
x={chartWidth / 2}
|
||||
y={chartHeight / 2}
|
||||
textAnchor="middle"
|
||||
className="text-gray-400 text-sm"
|
||||
>
|
||||
Waiting for data...
|
||||
</text>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Event count chart - shows event frequency over time
|
||||
*/
|
||||
export function EventCountChart({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const { events } = useSocket({ maxEvents: 100 });
|
||||
|
||||
// Group events by minute
|
||||
const countsByMinute = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
const now = Date.now();
|
||||
|
||||
// Initialize last 10 minutes
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const minute = Math.floor((now - i * 60000) / 60000);
|
||||
counts[minute] = 0;
|
||||
}
|
||||
|
||||
// Count events
|
||||
events.forEach((e) => {
|
||||
const minute = Math.floor(new Date(e.timestamp).getTime() / 60000);
|
||||
if (counts[minute] !== undefined) {
|
||||
counts[minute]++;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(counts)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([, count]) => count);
|
||||
}, [events]);
|
||||
|
||||
const maxCount = Math.max(...countsByMinute, 1);
|
||||
|
||||
return (
|
||||
<div className={classNames('bg-white rounded-lg p-4 border border-gray-200', className)}>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Events per Minute</h4>
|
||||
|
||||
<div className="flex items-end gap-1 h-16">
|
||||
{countsByMinute.map((count, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-blue-500 rounded-t transition-all duration-300"
|
||||
style={{ height: `${(count / maxCount) * 100}%` }}
|
||||
title={`${count} events`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>10m ago</span>
|
||||
<span>Now</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveChart;
|
||||
255
components/realtime/LiveFeed.tsx
Normal file
255
components/realtime/LiveFeed.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* Live Feed Component
|
||||
*
|
||||
* Displays a real-time feed of events from the LocalGreenChain system.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useLiveFeed } from '../../lib/realtime/useSocket';
|
||||
import type { LiveFeedItem, RoomType, TransparencyEventType } from '../../lib/realtime/types';
|
||||
import { EventCategory, getEventCategory } from '../../lib/realtime/events';
|
||||
import { ConnectionStatus } from './ConnectionStatus';
|
||||
|
||||
interface LiveFeedProps {
|
||||
rooms?: RoomType[];
|
||||
eventTypes?: TransparencyEventType[];
|
||||
maxItems?: number;
|
||||
showConnectionStatus?: boolean;
|
||||
showTimestamps?: boolean;
|
||||
showClearButton?: boolean;
|
||||
filterCategory?: EventCategory;
|
||||
className?: string;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
*/
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - timestamp;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
|
||||
if (diffSec < 60) {
|
||||
return 'Just now';
|
||||
} else if (diffMin < 60) {
|
||||
return `${diffMin}m ago`;
|
||||
} else if (diffHour < 24) {
|
||||
return `${diffHour}h ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color classes for event type
|
||||
*/
|
||||
function getColorClasses(color: string): { bg: string; border: string; text: string } {
|
||||
switch (color) {
|
||||
case 'green':
|
||||
return {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
text: 'text-green-800',
|
||||
};
|
||||
case 'blue':
|
||||
return {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
text: 'text-blue-800',
|
||||
};
|
||||
case 'yellow':
|
||||
return {
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
text: 'text-yellow-800',
|
||||
};
|
||||
case 'red':
|
||||
return {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
text: 'text-red-800',
|
||||
};
|
||||
case 'purple':
|
||||
return {
|
||||
bg: 'bg-purple-50',
|
||||
border: 'border-purple-200',
|
||||
text: 'text-purple-800',
|
||||
};
|
||||
case 'gray':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-gray-50',
|
||||
border: 'border-gray-200',
|
||||
text: 'text-gray-800',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single feed item component
|
||||
*/
|
||||
function FeedItem({
|
||||
item,
|
||||
showTimestamp,
|
||||
}: {
|
||||
item: LiveFeedItem;
|
||||
showTimestamp: boolean;
|
||||
}) {
|
||||
const colors = getColorClasses(item.formatted.color);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'p-3 rounded-lg border transition-all duration-300 animate-fadeIn',
|
||||
colors.bg,
|
||||
colors.border
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<span className="text-xl flex-shrink-0" role="img" aria-label={item.formatted.title}>
|
||||
{item.formatted.icon}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={classNames('font-medium text-sm', colors.text)}>
|
||||
{item.formatted.title}
|
||||
</span>
|
||||
{showTimestamp && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{formatTimestamp(item.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1 truncate">
|
||||
{item.formatted.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live Feed component
|
||||
*/
|
||||
export function LiveFeed({
|
||||
rooms,
|
||||
eventTypes,
|
||||
maxItems = 20,
|
||||
showConnectionStatus = true,
|
||||
showTimestamps = true,
|
||||
showClearButton = true,
|
||||
filterCategory,
|
||||
className,
|
||||
emptyMessage = 'No events yet. Real-time updates will appear here.',
|
||||
}: LiveFeedProps) {
|
||||
const { items, isConnected, status, clearFeed } = useLiveFeed({
|
||||
rooms,
|
||||
eventTypes,
|
||||
maxEvents: maxItems,
|
||||
});
|
||||
|
||||
// Filter items by category if specified
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!filterCategory) return items;
|
||||
|
||||
return items.filter((item) => {
|
||||
const category = getEventCategory(item.event.type);
|
||||
return category === filterCategory;
|
||||
});
|
||||
}, [items, filterCategory]);
|
||||
|
||||
return (
|
||||
<div className={classNames('flex flex-col h-full', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Live Feed</h3>
|
||||
{showConnectionStatus && <ConnectionStatus size="sm" showLabel={false} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{filteredItems.length > 0 && (
|
||||
<span className="text-sm text-gray-500">
|
||||
{filteredItems.length} event{filteredItems.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{showClearButton && filteredItems.length > 0 && (
|
||||
<button
|
||||
onClick={clearFeed}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feed content */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-2">📡</div>
|
||||
<p className="text-gray-500 text-sm">{emptyMessage}</p>
|
||||
{!isConnected && (
|
||||
<p className="text-yellow-600 text-xs mt-2">
|
||||
Status: {status}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => (
|
||||
<FeedItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
showTimestamp={showTimestamps}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact live feed for sidebars
|
||||
*/
|
||||
export function CompactLiveFeed({
|
||||
maxItems = 5,
|
||||
className,
|
||||
}: {
|
||||
maxItems?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const { items } = useLiveFeed({ maxEvents: maxItems });
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('space-y-1', className)}>
|
||||
{items.slice(0, maxItems).map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 py-1 text-sm"
|
||||
>
|
||||
<span>{item.formatted.icon}</span>
|
||||
<span className="truncate text-gray-600">
|
||||
{item.formatted.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LiveFeed;
|
||||
325
components/realtime/NotificationToast.tsx
Normal file
325
components/realtime/NotificationToast.tsx
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
/**
|
||||
* Notification Toast Component
|
||||
*
|
||||
* Displays real-time notifications as toast messages.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useSocketContext } from '../../lib/realtime/SocketContext';
|
||||
import type { RealtimeNotification } from '../../lib/realtime/types';
|
||||
|
||||
interface NotificationToastProps {
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left';
|
||||
maxVisible?: number;
|
||||
autoHideDuration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get position classes
|
||||
*/
|
||||
function getPositionClasses(position: NotificationToastProps['position']): string {
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
return 'top-4 left-4';
|
||||
case 'bottom-right':
|
||||
return 'bottom-4 right-4';
|
||||
case 'bottom-left':
|
||||
return 'bottom-4 left-4';
|
||||
case 'top-right':
|
||||
default:
|
||||
return 'top-4 right-4';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification type styles
|
||||
*/
|
||||
function getTypeStyles(type: RealtimeNotification['type']): {
|
||||
bg: string;
|
||||
border: string;
|
||||
icon: string;
|
||||
iconColor: string;
|
||||
} {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
icon: '✓',
|
||||
iconColor: 'text-green-600',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-yellow-50',
|
||||
border: 'border-yellow-200',
|
||||
icon: '⚠',
|
||||
iconColor: 'text-yellow-600',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
icon: '✕',
|
||||
iconColor: 'text-red-600',
|
||||
};
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
icon: 'ℹ',
|
||||
iconColor: 'text-blue-600',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single toast notification
|
||||
*/
|
||||
function Toast({
|
||||
notification,
|
||||
onDismiss,
|
||||
autoHideDuration,
|
||||
}: {
|
||||
notification: RealtimeNotification;
|
||||
onDismiss: (id: string) => void;
|
||||
autoHideDuration: number;
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
const styles = getTypeStyles(notification.type);
|
||||
|
||||
// Animate in
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Auto hide
|
||||
useEffect(() => {
|
||||
if (autoHideDuration <= 0) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
handleDismiss();
|
||||
}, autoHideDuration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoHideDuration]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
setIsLeaving(true);
|
||||
setTimeout(() => {
|
||||
onDismiss(notification.id);
|
||||
}, 300);
|
||||
}, [notification.id, onDismiss]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'max-w-sm w-full p-4 rounded-lg border shadow-lg transition-all duration-300',
|
||||
styles.bg,
|
||||
styles.border,
|
||||
{
|
||||
'opacity-0 translate-x-4': !isVisible || isLeaving,
|
||||
'opacity-100 translate-x-0': isVisible && !isLeaving,
|
||||
}
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<span className={classNames('text-xl font-bold flex-shrink-0', styles.iconColor)}>
|
||||
{styles.icon}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 text-sm">
|
||||
{notification.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{notification.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification Toast container
|
||||
*/
|
||||
export function NotificationToast({
|
||||
position = 'top-right',
|
||||
maxVisible = 5,
|
||||
autoHideDuration = 5000,
|
||||
className,
|
||||
}: NotificationToastProps) {
|
||||
const { notifications, dismissNotification } = useSocketContext();
|
||||
|
||||
// Only show non-read, non-dismissed notifications
|
||||
const visibleNotifications = notifications
|
||||
.filter((n) => !n.read && !n.dismissed)
|
||||
.slice(0, maxVisible);
|
||||
|
||||
if (visibleNotifications.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'fixed z-50 flex flex-col gap-2',
|
||||
getPositionClasses(position),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{visibleNotifications.map((notification) => (
|
||||
<Toast
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onDismiss={dismissNotification}
|
||||
autoHideDuration={autoHideDuration}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification bell with badge
|
||||
*/
|
||||
export function NotificationBell({
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { unreadCount } = useSocketContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
'relative p-2 text-gray-600 hover:text-gray-900 transition-colors',
|
||||
className
|
||||
)}
|
||||
aria-label={`Notifications${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
|
||||
>
|
||||
{/* Bell icon */}
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-0 right-0 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold leading-none text-white transform translate-x-1/2 -translate-y-1/2 bg-red-500 rounded-full">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification list dropdown
|
||||
*/
|
||||
export function NotificationList({
|
||||
className,
|
||||
onClose,
|
||||
}: {
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { notifications, markNotificationRead, markAllRead } = useSocketContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-80 max-h-96 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<h3 className="font-semibold text-gray-900">Notifications</h3>
|
||||
{notifications.length > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="overflow-y-auto max-h-72">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<div className="text-3xl mb-2">🔔</div>
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={classNames(
|
||||
'px-4 py-3 border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors',
|
||||
{ 'bg-blue-50': !notification.read }
|
||||
)}
|
||||
onClick={() => markNotificationRead(notification.id)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">
|
||||
{getTypeStyles(notification.type).icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={classNames('text-sm', { 'font-medium': !notification.read })}>
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{new Date(notification.timestamp).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationToast;
|
||||
27
components/realtime/index.ts
Normal file
27
components/realtime/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Real-Time Components for LocalGreenChain
|
||||
*
|
||||
* Export all real-time UI components.
|
||||
*/
|
||||
|
||||
export {
|
||||
ConnectionStatus,
|
||||
ConnectionDot,
|
||||
ConnectionBanner,
|
||||
} from './ConnectionStatus';
|
||||
|
||||
export {
|
||||
LiveFeed,
|
||||
CompactLiveFeed,
|
||||
} from './LiveFeed';
|
||||
|
||||
export {
|
||||
NotificationToast,
|
||||
NotificationBell,
|
||||
NotificationList,
|
||||
} from './NotificationToast';
|
||||
|
||||
export {
|
||||
LiveChart,
|
||||
EventCountChart,
|
||||
} from './LiveChart';
|
||||
236
components/upload/DocumentUploader.tsx
Normal file
236
components/upload/DocumentUploader.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
/**
|
||||
* Document Uploader Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Upload interface for documents (PDF, DOC, etc.)
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import type { FileCategory } from '../../lib/storage/types';
|
||||
import ProgressBar from './ProgressBar';
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
originalName: string;
|
||||
}
|
||||
|
||||
interface DocumentUploaderProps {
|
||||
category?: FileCategory;
|
||||
plantId?: string;
|
||||
farmId?: string;
|
||||
userId?: string;
|
||||
onUpload?: (file: UploadedDocument) => void;
|
||||
onError?: (error: string) => void;
|
||||
accept?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DocumentUploader({
|
||||
category = 'document',
|
||||
plantId,
|
||||
farmId,
|
||||
userId,
|
||||
onUpload,
|
||||
onError,
|
||||
accept = '.pdf,.doc,.docx',
|
||||
className = '',
|
||||
}: DocumentUploaderProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string>();
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedDocument | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setIsUploading(true);
|
||||
setProgress(10);
|
||||
setError(undefined);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (plantId) formData.append('plantId', plantId);
|
||||
if (farmId) formData.append('farmId', farmId);
|
||||
if (userId) formData.append('userId', userId);
|
||||
|
||||
try {
|
||||
setProgress(30);
|
||||
|
||||
const response = await fetch('/api/upload/document', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
setProgress(80);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
setProgress(100);
|
||||
setUploadedFile(data.file);
|
||||
onUpload?.(data.file);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||
setError(message);
|
||||
onError?.(message);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await uploadFile(file);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setUploadedFile(null);
|
||||
setProgress(0);
|
||||
setError(undefined);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (mimeType: string) => {
|
||||
if (mimeType === 'application/pdf') {
|
||||
return (
|
||||
<svg className="w-8 h-8 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zm-3 9.5c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5zm3 3c0 .83-.67 1.5-1.5 1.5s-1.5-.67-1.5-1.5.67-1.5 1.5-1.5 1.5.67 1.5 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="w-8 h-8 text-blue-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm4 18H6V4h7v5h5v11z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadedFile ? (
|
||||
<div className="flex items-center p-4 border rounded-lg bg-gray-50">
|
||||
{getFileIcon(uploadedFile.mimeType)}
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{uploadedFile.originalName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(uploadedFile.size)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<a
|
||||
href={uploadedFile.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-green-600 hover:text-green-700"
|
||||
title="Download"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
title="Remove"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isUploading}
|
||||
className={`
|
||||
w-full border-2 border-dashed rounded-lg p-6 text-center
|
||||
transition-colors duration-200
|
||||
${isUploading
|
||||
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
||||
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50 cursor-pointer'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="mx-auto h-10 w-10 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="text-green-600 font-medium">Click to upload</span>
|
||||
{' '}a document
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PDF, DOC, DOCX up to 10MB
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className="mt-3">
|
||||
<ProgressBar progress={progress} />
|
||||
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-500 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentUploader;
|
||||
266
components/upload/ImageUploader.tsx
Normal file
266
components/upload/ImageUploader.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Image Uploader Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Drag & drop image upload with preview and progress
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import type { FileCategory } from '../../lib/storage/types';
|
||||
|
||||
interface UploadedFile {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ImageUploaderProps {
|
||||
category?: FileCategory;
|
||||
plantId?: string;
|
||||
farmId?: string;
|
||||
userId?: string;
|
||||
onUpload?: (file: UploadedFile) => void;
|
||||
onError?: (error: string) => void;
|
||||
maxFiles?: number;
|
||||
accept?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface UploadState {
|
||||
isUploading: boolean;
|
||||
progress: number;
|
||||
error?: string;
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export function ImageUploader({
|
||||
category = 'plant-photo',
|
||||
plantId,
|
||||
farmId,
|
||||
userId,
|
||||
onUpload,
|
||||
onError,
|
||||
maxFiles = 1,
|
||||
accept = 'image/*',
|
||||
className = '',
|
||||
}: ImageUploaderProps) {
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
isUploading: false,
|
||||
progress: 0,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
preview: e.target?.result as string,
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Start upload
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
isUploading: true,
|
||||
progress: 0,
|
||||
error: undefined,
|
||||
}));
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('category', category);
|
||||
if (plantId) formData.append('plantId', plantId);
|
||||
if (farmId) formData.append('farmId', farmId);
|
||||
if (userId) formData.append('userId', userId);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
progress: 100,
|
||||
preview: data.file.thumbnailUrl || data.file.url,
|
||||
});
|
||||
|
||||
onUpload?.(data.file);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||
setUploadState((prev) => ({
|
||||
...prev,
|
||||
isUploading: false,
|
||||
error: message,
|
||||
}));
|
||||
onError?.(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files).slice(0, maxFiles);
|
||||
if (files.length > 0) {
|
||||
await uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[maxFiles]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []).slice(0, maxFiles);
|
||||
if (files.length > 0) {
|
||||
await uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[maxFiles]
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setUploadState({
|
||||
isUploading: false,
|
||||
progress: 0,
|
||||
preview: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{uploadState.preview ? (
|
||||
<div className="relative rounded-lg overflow-hidden border-2 border-green-200">
|
||||
<img
|
||||
src={uploadState.preview}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
|
||||
title="Remove image"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer
|
||||
transition-colors duration-200
|
||||
${isDragging
|
||||
? 'border-green-500 bg-green-50'
|
||||
: 'border-gray-300 hover:border-green-400 hover:bg-gray-50'
|
||||
}
|
||||
${uploadState.isUploading ? 'pointer-events-none opacity-50' : ''}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
viewBox="0 0 48 48"
|
||||
>
|
||||
<path
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<span className="text-green-600 font-medium">Click to upload</span>
|
||||
{' '}or drag and drop
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
PNG, JPG, GIF, WEBP up to 5MB
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState.isUploading && (
|
||||
<div className="mt-2">
|
||||
<div className="bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadState.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 text-center">Uploading...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState.error && (
|
||||
<p className="mt-2 text-sm text-red-500 text-center">{uploadState.error}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageUploader;
|
||||
213
components/upload/PhotoGallery.tsx
Normal file
213
components/upload/PhotoGallery.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Photo Gallery Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Displays a grid of plant photos with lightbox view
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
caption?: string;
|
||||
uploadedAt?: string;
|
||||
}
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
photos: Photo[];
|
||||
onDelete?: (photoId: string) => void;
|
||||
editable?: boolean;
|
||||
columns?: 2 | 3 | 4;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhotoGallery({
|
||||
photos,
|
||||
onDelete,
|
||||
editable = false,
|
||||
columns = 3,
|
||||
className = '',
|
||||
}: PhotoGalleryProps) {
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (photoId: string) => {
|
||||
if (!onDelete) return;
|
||||
|
||||
setIsDeleting(photoId);
|
||||
try {
|
||||
await onDelete(photoId);
|
||||
} finally {
|
||||
setIsDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const gridCols = {
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-2 sm:grid-cols-3',
|
||||
4: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4',
|
||||
};
|
||||
|
||||
if (photos.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 ${className}`}>
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-gray-500">No photos yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`grid ${gridCols[columns]} gap-4 ${className}`}>
|
||||
{photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
|
||||
>
|
||||
<img
|
||||
src={photo.thumbnailUrl || photo.url}
|
||||
alt={photo.caption || 'Plant photo'}
|
||||
className="w-full h-full object-cover cursor-pointer transition-transform duration-200 group-hover:scale-105"
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Overlay on hover */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedPhoto(photo)}
|
||||
className="opacity-0 group-hover:opacity-100 bg-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-gray-100"
|
||||
title="View full size"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-700"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{editable && onDelete && (
|
||||
<button
|
||||
onClick={() => handleDelete(photo.id)}
|
||||
disabled={isDeleting === photo.id}
|
||||
className="opacity-0 group-hover:opacity-100 bg-red-500 text-white rounded-full p-2 mx-1 transition-opacity duration-200 hover:bg-red-600 disabled:opacity-50"
|
||||
title="Delete photo"
|
||||
>
|
||||
{isDeleting === photo.id ? (
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
{photo.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
|
||||
<p className="text-white text-xs truncate">{photo.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 transition-colors"
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={selectedPhoto.url}
|
||||
alt={selectedPhoto.caption || 'Plant photo'}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{selectedPhoto.caption && (
|
||||
<div className="absolute bottom-4 left-4 right-4 text-center">
|
||||
<p className="text-white text-lg">{selectedPhoto.caption}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PhotoGallery;
|
||||
63
components/upload/ProgressBar.tsx
Normal file
63
components/upload/ProgressBar.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Progress Bar Component
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Animated progress bar for uploads
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
showPercentage?: boolean;
|
||||
color?: 'green' | 'blue' | 'purple' | 'orange';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const colorClasses = {
|
||||
green: 'bg-green-500',
|
||||
blue: 'bg-blue-500',
|
||||
purple: 'bg-purple-500',
|
||||
orange: 'bg-orange-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'h-1',
|
||||
md: 'h-2',
|
||||
lg: 'h-3',
|
||||
};
|
||||
|
||||
export function ProgressBar({
|
||||
progress,
|
||||
showPercentage = false,
|
||||
color = 'green',
|
||||
size = 'md',
|
||||
animated = true,
|
||||
className = '',
|
||||
}: ProgressBarProps) {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={`bg-gray-200 rounded-full overflow-hidden ${sizeClasses[size]}`}>
|
||||
<div
|
||||
className={`
|
||||
${colorClasses[color]} ${sizeClasses[size]} rounded-full
|
||||
transition-all duration-300 ease-out
|
||||
${animated && clampedProgress < 100 ? 'animate-pulse' : ''}
|
||||
`}
|
||||
style={{ width: `${clampedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<p className="text-xs text-gray-500 mt-1 text-right">
|
||||
{Math.round(clampedProgress)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProgressBar;
|
||||
11
components/upload/index.tsx
Normal file
11
components/upload/index.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Upload Components Index
|
||||
* Agent 3: File Upload & Storage System
|
||||
*
|
||||
* Export all upload-related components
|
||||
*/
|
||||
|
||||
export { ImageUploader } from './ImageUploader';
|
||||
export { PhotoGallery } from './PhotoGallery';
|
||||
export { DocumentUploader } from './DocumentUploader';
|
||||
export { ProgressBar } from './ProgressBar';
|
||||
35
cypress.config.ts
Normal file
35
cypress.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:3001',
|
||||
supportFile: 'cypress/support/e2e.ts',
|
||||
specPattern: 'cypress/e2e/**/*.cy.{ts,tsx}',
|
||||
viewportWidth: 1280,
|
||||
viewportHeight: 720,
|
||||
video: true,
|
||||
screenshotOnRunFailure: true,
|
||||
defaultCommandTimeout: 10000,
|
||||
requestTimeout: 10000,
|
||||
responseTimeout: 30000,
|
||||
retries: {
|
||||
runMode: 2,
|
||||
openMode: 0,
|
||||
},
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
on('task', {
|
||||
log(message) {
|
||||
console.log(message);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
component: {
|
||||
devServer: {
|
||||
framework: 'next',
|
||||
bundler: 'webpack',
|
||||
},
|
||||
},
|
||||
});
|
||||
32
cypress/e2e/home.cy.ts
Normal file
32
cypress/e2e/home.cy.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Home Page E2E Tests
|
||||
*/
|
||||
|
||||
describe('Home Page', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/');
|
||||
cy.waitForPageLoad();
|
||||
});
|
||||
|
||||
it('should load the home page', () => {
|
||||
cy.url().should('eq', `${Cypress.config('baseUrl')}/`);
|
||||
});
|
||||
|
||||
it('should display the main navigation', () => {
|
||||
cy.get('nav').should('be.visible');
|
||||
});
|
||||
|
||||
it('should have proper page title', () => {
|
||||
cy.title().should('not.be.empty');
|
||||
});
|
||||
|
||||
it('should be responsive on mobile viewport', () => {
|
||||
cy.viewport('iphone-x');
|
||||
cy.get('nav').should('be.visible');
|
||||
});
|
||||
|
||||
it('should be responsive on tablet viewport', () => {
|
||||
cy.viewport('ipad-2');
|
||||
cy.get('nav').should('be.visible');
|
||||
});
|
||||
});
|
||||
51
cypress/e2e/plant-registration.cy.ts
Normal file
51
cypress/e2e/plant-registration.cy.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Plant Registration E2E Tests
|
||||
*/
|
||||
|
||||
describe('Plant Registration', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/plants/register');
|
||||
cy.waitForPageLoad();
|
||||
});
|
||||
|
||||
it('should load the registration page', () => {
|
||||
cy.url().should('include', '/plants/register');
|
||||
});
|
||||
|
||||
it('should display registration form', () => {
|
||||
cy.get('form').should('be.visible');
|
||||
});
|
||||
|
||||
it('should have required form fields', () => {
|
||||
// Check for common form fields
|
||||
cy.get('input, select, textarea').should('have.length.at.least', 1);
|
||||
});
|
||||
|
||||
it('should show validation errors for empty form submission', () => {
|
||||
// Try to submit empty form
|
||||
cy.get('form').within(() => {
|
||||
cy.get('button[type="submit"]').click();
|
||||
});
|
||||
// Form should not navigate away without valid data
|
||||
cy.url().should('include', '/plants/register');
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('should require plant name', () => {
|
||||
cy.get('input[name="name"]').should('exist');
|
||||
});
|
||||
|
||||
it('should require plant species', () => {
|
||||
cy.get('input[name="species"], select[name="species"]').should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anonymous Registration', () => {
|
||||
it('should allow anonymous registration', () => {
|
||||
cy.visit('/plants/register-anonymous');
|
||||
cy.waitForPageLoad();
|
||||
cy.url().should('include', '/plants/register-anonymous');
|
||||
cy.get('form').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
49
cypress/e2e/transparency.cy.ts
Normal file
49
cypress/e2e/transparency.cy.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Transparency Dashboard E2E Tests
|
||||
*/
|
||||
|
||||
describe('Transparency Dashboard', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/transparency');
|
||||
cy.waitForPageLoad();
|
||||
});
|
||||
|
||||
it('should load the transparency page', () => {
|
||||
cy.url().should('include', '/transparency');
|
||||
});
|
||||
|
||||
it('should display dashboard content', () => {
|
||||
cy.get('main').should('be.visible');
|
||||
});
|
||||
|
||||
it('should show transparency metrics', () => {
|
||||
// Check for dashboard sections
|
||||
cy.get('[data-testid="dashboard"], .dashboard, main').should('be.visible');
|
||||
});
|
||||
|
||||
describe('Data Display', () => {
|
||||
it('should display charts or data visualizations', () => {
|
||||
// Look for chart containers or data elements
|
||||
cy.get('canvas, svg, [class*="chart"], [class*="graph"]').should(
|
||||
'have.length.at.least',
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('should display audit information', () => {
|
||||
// Check for audit-related content
|
||||
cy.contains(/audit|log|record|history/i).should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading structure', () => {
|
||||
cy.get('h1, h2, h3').should('have.length.at.least', 1);
|
||||
});
|
||||
|
||||
it('should be keyboard navigable', () => {
|
||||
cy.get('body').tab();
|
||||
cy.focused().should('exist');
|
||||
});
|
||||
});
|
||||
});
|
||||
59
cypress/e2e/vertical-farm.cy.ts
Normal file
59
cypress/e2e/vertical-farm.cy.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Vertical Farm E2E Tests
|
||||
*/
|
||||
|
||||
describe('Vertical Farm', () => {
|
||||
describe('Farm List Page', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/vertical-farm');
|
||||
cy.waitForPageLoad();
|
||||
});
|
||||
|
||||
it('should load the vertical farm page', () => {
|
||||
cy.url().should('include', '/vertical-farm');
|
||||
});
|
||||
|
||||
it('should display farm management content', () => {
|
||||
cy.get('main').should('be.visible');
|
||||
});
|
||||
|
||||
it('should have navigation to register new farm', () => {
|
||||
cy.contains(/register|new|add|create/i).should('exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Farm Registration', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit('/vertical-farm/register');
|
||||
cy.waitForPageLoad();
|
||||
});
|
||||
|
||||
it('should load the registration page', () => {
|
||||
cy.url().should('include', '/vertical-farm/register');
|
||||
});
|
||||
|
||||
it('should display registration form', () => {
|
||||
cy.get('form').should('be.visible');
|
||||
});
|
||||
|
||||
it('should have required form fields', () => {
|
||||
cy.get('input, select, textarea').should('have.length.at.least', 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsiveness', () => {
|
||||
it('should display correctly on mobile', () => {
|
||||
cy.viewport('iphone-x');
|
||||
cy.visit('/vertical-farm');
|
||||
cy.waitForPageLoad();
|
||||
cy.get('main').should('be.visible');
|
||||
});
|
||||
|
||||
it('should display correctly on tablet', () => {
|
||||
cy.viewport('ipad-2');
|
||||
cy.visit('/vertical-farm');
|
||||
cy.waitForPageLoad();
|
||||
cy.get('main').should('be.visible');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,25 @@
|
|||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
"plants": [
|
||||
{
|
||||
"id": "plant-1",
|
||||
"name": "Cherry Tomato",
|
||||
"species": "Tomato",
|
||||
"variety": "Cherry",
|
||||
"generation": 1,
|
||||
"status": "healthy"
|
||||
},
|
||||
{
|
||||
"id": "plant-2",
|
||||
"name": "Sweet Basil",
|
||||
"species": "Basil",
|
||||
"variety": "Genovese",
|
||||
"generation": 1,
|
||||
"status": "thriving"
|
||||
}
|
||||
],
|
||||
"user": {
|
||||
"id": "user-1",
|
||||
"email": "test@example.com",
|
||||
"name": "Test User"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
cypress/support/commands.ts
Normal file
27
cypress/support/commands.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Cypress Custom Commands
|
||||
*/
|
||||
|
||||
// Wait for page to fully load
|
||||
Cypress.Commands.add('waitForPageLoad', () => {
|
||||
cy.document().its('readyState').should('eq', 'complete');
|
||||
});
|
||||
|
||||
// Login command (placeholder for auth implementation)
|
||||
Cypress.Commands.add('login', (email: string, password: string) => {
|
||||
// This will be implemented when auth is added
|
||||
cy.log(`Login with ${email}`);
|
||||
cy.session([email, password], () => {
|
||||
// Placeholder for auth session
|
||||
cy.visit('/');
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate to a plant page
|
||||
Cypress.Commands.add('visitPlant', (plantId: string) => {
|
||||
cy.visit(`/plants/${plantId}`);
|
||||
cy.waitForPageLoad();
|
||||
});
|
||||
|
||||
// Export empty object for module
|
||||
export {};
|
||||
45
cypress/support/e2e.ts
Normal file
45
cypress/support/e2e.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Cypress E2E Support File
|
||||
* This file is processed and loaded automatically before test files.
|
||||
*/
|
||||
|
||||
// Import commands
|
||||
import './commands';
|
||||
|
||||
// Global hooks
|
||||
beforeEach(() => {
|
||||
// Clear local storage between tests
|
||||
cy.clearLocalStorage();
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
Cypress.on('uncaught:exception', (err, runnable) => {
|
||||
// Returning false prevents Cypress from failing the test
|
||||
// This is useful for third-party scripts that may throw errors
|
||||
if (err.message.includes('ResizeObserver loop')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add custom assertions if needed
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/**
|
||||
* Custom command to wait for page load
|
||||
*/
|
||||
waitForPageLoad(): Chainable<void>;
|
||||
|
||||
/**
|
||||
* Custom command to login (placeholder for auth tests)
|
||||
*/
|
||||
login(email: string, password: string): Chainable<void>;
|
||||
|
||||
/**
|
||||
* Custom command to navigate to a plant page
|
||||
*/
|
||||
visitPlant(plantId: string): Chainable<void>;
|
||||
}
|
||||
}
|
||||
}
|
||||
263
deploy/NetworkDiscoveryAgent.ts
Normal file
263
deploy/NetworkDiscoveryAgent.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env bun
|
||||
/**
|
||||
* NetworkDiscoveryAgent Deployment Script
|
||||
* Agent 8 - Geographic Network Discovery and Analysis
|
||||
*
|
||||
* This script provides standalone deployment for the NetworkDiscoveryAgent,
|
||||
* which maps and analyzes the geographic distribution of the plant network.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Map plant distribution across regions
|
||||
* - Identify network hotspots and clusters
|
||||
* - Suggest grower/consumer connections
|
||||
* - Track network growth patterns
|
||||
* - Detect coverage gaps
|
||||
*
|
||||
* Usage:
|
||||
* bun run deploy/NetworkDiscoveryAgent.ts
|
||||
* bun run deploy:network-discovery
|
||||
*
|
||||
* Environment Variables:
|
||||
* AGENT_INTERVAL_MS - Execution interval (default: 600000 = 10 min)
|
||||
* AGENT_LOG_LEVEL - Log level: debug, info, warn, error (default: info)
|
||||
* AGENT_AUTO_RESTART - Auto-restart on failure (default: true)
|
||||
* AGENT_MAX_RETRIES - Max retry attempts (default: 3)
|
||||
*/
|
||||
|
||||
import { getNetworkDiscoveryAgent, NetworkDiscoveryAgent } from '../lib/agents/NetworkDiscoveryAgent';
|
||||
|
||||
// Configuration from environment
|
||||
const config = {
|
||||
intervalMs: parseInt(process.env.AGENT_INTERVAL_MS || '600000'),
|
||||
logLevel: process.env.AGENT_LOG_LEVEL || 'info',
|
||||
autoRestart: process.env.AGENT_AUTO_RESTART !== 'false',
|
||||
maxRetries: parseInt(process.env.AGENT_MAX_RETRIES || '3'),
|
||||
};
|
||||
|
||||
// Logger utility
|
||||
const log = {
|
||||
debug: (...args: any[]) => config.logLevel === 'debug' && console.log('[DEBUG]', ...args),
|
||||
info: (...args: any[]) => ['debug', 'info'].includes(config.logLevel) && console.log('[INFO]', ...args),
|
||||
warn: (...args: any[]) => ['debug', 'info', 'warn'].includes(config.logLevel) && console.warn('[WARN]', ...args),
|
||||
error: (...args: any[]) => console.error('[ERROR]', ...args),
|
||||
};
|
||||
|
||||
/**
|
||||
* Format uptime as human-readable string
|
||||
*/
|
||||
function formatUptime(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display agent status
|
||||
*/
|
||||
function displayStatus(agent: NetworkDiscoveryAgent): void {
|
||||
const metrics = agent.getMetrics();
|
||||
const analysis = agent.getNetworkAnalysis();
|
||||
const clusters = agent.getClusters();
|
||||
const gaps = agent.getCoverageGaps();
|
||||
const suggestions = agent.getConnectionSuggestions();
|
||||
const growth = agent.getGrowthHistory();
|
||||
const regions = agent.getRegionalStats();
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' NETWORK DISCOVERY AGENT - STATUS REPORT');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Agent Metrics
|
||||
console.log('\n AGENT METRICS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
console.log(` Status: ${agent.status}`);
|
||||
console.log(` Uptime: ${formatUptime(metrics.uptime)}`);
|
||||
console.log(` Tasks Completed: ${metrics.tasksCompleted}`);
|
||||
console.log(` Tasks Failed: ${metrics.tasksFailed}`);
|
||||
console.log(` Avg Execution: ${Math.round(metrics.averageExecutionMs)}ms`);
|
||||
console.log(` Last Run: ${metrics.lastRunAt || 'Never'}`);
|
||||
|
||||
// Network Analysis
|
||||
console.log('\n NETWORK ANALYSIS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
console.log(` Total Nodes: ${analysis.totalNodes}`);
|
||||
console.log(` Connections: ${analysis.totalConnections}`);
|
||||
console.log(` Clusters: ${clusters.length}`);
|
||||
console.log(` Hotspots: ${analysis.hotspots.length}`);
|
||||
console.log(` Coverage Gaps: ${gaps.length}`);
|
||||
console.log(` Suggestions: ${suggestions.length}`);
|
||||
|
||||
// Cluster Details
|
||||
if (clusters.length > 0) {
|
||||
console.log('\n TOP CLUSTERS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
const topClusters = clusters.slice(0, 5);
|
||||
for (const cluster of topClusters) {
|
||||
console.log(` - ${cluster.activityLevel.toUpperCase()} activity cluster`);
|
||||
console.log(` Nodes: ${cluster.nodes.length}, Radius: ${cluster.radius}km`);
|
||||
if (cluster.dominantSpecies.length > 0) {
|
||||
console.log(` Species: ${cluster.dominantSpecies.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage Gaps
|
||||
if (gaps.length > 0) {
|
||||
console.log('\n COVERAGE GAPS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
for (const gap of gaps.slice(0, 3)) {
|
||||
console.log(` - ${gap.populationDensity.toUpperCase()} area`);
|
||||
console.log(` Distance to nearest: ${gap.distanceToNearest}km`);
|
||||
console.log(` Potential demand: ${gap.potentialDemand}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Top Suggestions
|
||||
if (suggestions.length > 0) {
|
||||
console.log('\n TOP CONNECTION SUGGESTIONS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
for (const suggestion of suggestions.slice(0, 3)) {
|
||||
console.log(` - Strength: ${suggestion.strength}%`);
|
||||
console.log(` Distance: ${suggestion.distance}km`);
|
||||
console.log(` Reason: ${suggestion.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Regional Stats
|
||||
if (regions.length > 0) {
|
||||
console.log('\n REGIONAL STATISTICS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
for (const region of regions) {
|
||||
if (region.nodeCount > 0) {
|
||||
console.log(` ${region.region}:`);
|
||||
console.log(` Nodes: ${region.nodeCount}, Plants: ${region.plantCount}`);
|
||||
console.log(` Species: ${region.uniqueSpecies}, Activity: ${region.avgActivityScore}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Growth Trend
|
||||
if (growth.length > 0) {
|
||||
const latest = growth[growth.length - 1];
|
||||
console.log('\n NETWORK GROWTH');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
console.log(` Total Nodes: ${latest.totalNodes}`);
|
||||
console.log(` Total Connections: ${latest.totalConnections}`);
|
||||
console.log(` New Nodes/Week: ${latest.newNodesWeek}`);
|
||||
console.log(` Geographic Span: ${latest.geographicExpansion}km`);
|
||||
}
|
||||
|
||||
// Alerts
|
||||
const alerts = agent.getAlerts();
|
||||
const unacknowledged = alerts.filter(a => !a.acknowledged);
|
||||
if (unacknowledged.length > 0) {
|
||||
console.log('\n ACTIVE ALERTS');
|
||||
console.log(' ' + '-'.repeat(40));
|
||||
for (const alert of unacknowledged.slice(0, 5)) {
|
||||
console.log(` [${alert.severity.toUpperCase()}] ${alert.title}`);
|
||||
console.log(` ${alert.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Main deployment function
|
||||
*/
|
||||
async function deploy(): Promise<void> {
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' DEPLOYING NETWORK DISCOVERY AGENT (Agent 8)');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`\n Configuration:`);
|
||||
console.log(` - Interval: ${config.intervalMs}ms (${config.intervalMs / 60000} min)`);
|
||||
console.log(` - Log Level: ${config.logLevel}`);
|
||||
console.log(` - Auto Restart: ${config.autoRestart}`);
|
||||
console.log(` - Max Retries: ${config.maxRetries}`);
|
||||
console.log('');
|
||||
|
||||
// Get agent instance
|
||||
const agent = getNetworkDiscoveryAgent();
|
||||
|
||||
// Register event handlers
|
||||
agent.on('task_completed', (data) => {
|
||||
log.info(`Task completed: ${JSON.stringify(data.result)}`);
|
||||
});
|
||||
|
||||
agent.on('task_failed', (data) => {
|
||||
log.error(`Task failed: ${data.error}`);
|
||||
});
|
||||
|
||||
agent.on('agent_started', () => {
|
||||
log.info('Network Discovery Agent started');
|
||||
});
|
||||
|
||||
agent.on('agent_stopped', () => {
|
||||
log.info('Network Discovery Agent stopped');
|
||||
});
|
||||
|
||||
// Start the agent
|
||||
log.info('Starting Network Discovery Agent...');
|
||||
|
||||
try {
|
||||
await agent.start();
|
||||
log.info('Agent started successfully');
|
||||
|
||||
// Run initial discovery
|
||||
log.info('Running initial network discovery...');
|
||||
await agent.runOnce();
|
||||
log.info('Initial discovery complete');
|
||||
|
||||
// Display initial status
|
||||
displayStatus(agent);
|
||||
|
||||
// Set up periodic status display
|
||||
const statusInterval = setInterval(() => {
|
||||
displayStatus(agent);
|
||||
}, config.intervalMs);
|
||||
|
||||
// Handle shutdown signals
|
||||
const shutdown = async (signal: string) => {
|
||||
log.info(`Received ${signal}, shutting down...`);
|
||||
clearInterval(statusInterval);
|
||||
|
||||
try {
|
||||
await agent.stop();
|
||||
log.info('Agent stopped gracefully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
log.error('Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
// Keep the process running
|
||||
log.info(`Agent running. Press Ctrl+C to stop.`);
|
||||
log.info(`Next discovery in ${config.intervalMs / 60000} minutes...`);
|
||||
|
||||
} catch (error) {
|
||||
log.error('Failed to start agent:', error);
|
||||
|
||||
if (config.autoRestart) {
|
||||
log.info('Auto-restart enabled, retrying in 10 seconds...');
|
||||
setTimeout(() => deploy(), 10000);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run deployment
|
||||
deploy().catch((error) => {
|
||||
console.error('Deployment failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
155
docker-compose.dev.yml
Normal file
155
docker-compose.dev.yml
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# LocalGreenChain Development Docker Compose
|
||||
# Agent 4: Production Deployment
|
||||
# Development environment with hot reloading and debug tools
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ==========================================================================
|
||||
# Application (Development Mode)
|
||||
# ==========================================================================
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: deps # Use deps stage for development
|
||||
container_name: lgc-app-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3001}:3001"
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- DATABASE_URL=postgresql://lgc:lgc_dev_password@postgres:5432/localgreenchain_dev
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- LOG_LEVEL=debug
|
||||
- PLANTS_NET_API_KEY=${PLANTS_NET_API_KEY:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# Mount source code for hot reloading
|
||||
- .:/app
|
||||
- /app/node_modules # Exclude node_modules
|
||||
- /app/.next # Exclude build output
|
||||
networks:
|
||||
- lgc-dev-network
|
||||
command: bun run dev
|
||||
|
||||
# ==========================================================================
|
||||
# Database (Development)
|
||||
# ==========================================================================
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: lgc-postgres-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=lgc
|
||||
- POSTGRES_PASSWORD=lgc_dev_password
|
||||
- POSTGRES_DB=localgreenchain_dev
|
||||
volumes:
|
||||
- postgres-dev-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5433:5432" # Different port to avoid conflicts
|
||||
networks:
|
||||
- lgc-dev-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U lgc -d localgreenchain_dev"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ==========================================================================
|
||||
# Cache (Development)
|
||||
# ==========================================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lgc-redis-dev
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis-dev-data:/data
|
||||
ports:
|
||||
- "6380:6379" # Different port to avoid conflicts
|
||||
networks:
|
||||
- lgc-dev-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ==========================================================================
|
||||
# Database Admin (pgAdmin)
|
||||
# ==========================================================================
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: lgc-pgadmin-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PGADMIN_DEFAULT_EMAIL=admin@localgreenchain.local
|
||||
- PGADMIN_DEFAULT_PASSWORD=admin
|
||||
- PGADMIN_CONFIG_SERVER_MODE=False
|
||||
volumes:
|
||||
- pgadmin-dev-data:/var/lib/pgadmin
|
||||
ports:
|
||||
- "5050:80"
|
||||
networks:
|
||||
- lgc-dev-network
|
||||
depends_on:
|
||||
- postgres
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
# ==========================================================================
|
||||
# Redis Commander (Redis UI)
|
||||
# ==========================================================================
|
||||
redis-commander:
|
||||
image: rediscommander/redis-commander:latest
|
||||
container_name: lgc-redis-commander-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- REDIS_HOSTS=local:redis:6379
|
||||
ports:
|
||||
- "8081:8081"
|
||||
networks:
|
||||
- lgc-dev-network
|
||||
depends_on:
|
||||
- redis
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
# ==========================================================================
|
||||
# MailHog (Email Testing)
|
||||
# ==========================================================================
|
||||
mailhog:
|
||||
image: mailhog/mailhog:latest
|
||||
container_name: lgc-mailhog-dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1025:1025" # SMTP
|
||||
- "8025:8025" # Web UI
|
||||
networks:
|
||||
- lgc-dev-network
|
||||
profiles:
|
||||
- tools
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
lgc-dev-network:
|
||||
driver: bridge
|
||||
name: lgc-dev-network
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
postgres-dev-data:
|
||||
name: lgc-postgres-dev-data
|
||||
redis-dev-data:
|
||||
name: lgc-redis-dev-data
|
||||
pgadmin-dev-data:
|
||||
name: lgc-pgadmin-dev-data
|
||||
164
docker-compose.yml
Normal file
164
docker-compose.yml
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# LocalGreenChain Production Docker Compose
|
||||
# Agent 4: Production Deployment
|
||||
# Full stack with PostgreSQL, Redis, and monitoring
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ==========================================================================
|
||||
# Application
|
||||
# ==========================================================================
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
- NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN:-}
|
||||
container_name: lgc-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3001}:3001"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://${DB_USER:-lgc}:${DB_PASSWORD:-lgc_password}@postgres:5432/${DB_NAME:-localgreenchain}
|
||||
- REDIS_URL=redis://redis:6379
|
||||
- SENTRY_DSN=${SENTRY_DSN:-}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- PLANTS_NET_API_KEY=${PLANTS_NET_API_KEY:-}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
networks:
|
||||
- lgc-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
labels:
|
||||
- "prometheus.scrape=true"
|
||||
- "prometheus.port=3001"
|
||||
- "prometheus.path=/api/metrics"
|
||||
|
||||
# ==========================================================================
|
||||
# Database
|
||||
# ==========================================================================
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: lgc-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-lgc}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD:-lgc_password}
|
||||
- POSTGRES_DB=${DB_NAME:-localgreenchain}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
networks:
|
||||
- lgc-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-lgc} -d ${DB_NAME:-localgreenchain}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
# ==========================================================================
|
||||
# Cache
|
||||
# ==========================================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lgc-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
networks:
|
||||
- lgc-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ==========================================================================
|
||||
# Monitoring - Prometheus
|
||||
# ==========================================================================
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.47.0
|
||||
container_name: lgc-prometheus
|
||||
restart: unless-stopped
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--storage.tsdb.retention.time=15d'
|
||||
- '--web.enable-lifecycle'
|
||||
volumes:
|
||||
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus-data:/prometheus
|
||||
ports:
|
||||
- "${PROMETHEUS_PORT:-9090}:9090"
|
||||
networks:
|
||||
- lgc-network
|
||||
depends_on:
|
||||
- app
|
||||
profiles:
|
||||
- monitoring
|
||||
|
||||
# ==========================================================================
|
||||
# Monitoring - Grafana
|
||||
# ==========================================================================
|
||||
grafana:
|
||||
image: grafana/grafana:10.1.0
|
||||
container_name: lgc-grafana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3000}
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
|
||||
ports:
|
||||
- "${GRAFANA_PORT:-3000}:3000"
|
||||
networks:
|
||||
- lgc-network
|
||||
depends_on:
|
||||
- prometheus
|
||||
profiles:
|
||||
- monitoring
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
lgc-network:
|
||||
driver: bridge
|
||||
name: lgc-network
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
app-data:
|
||||
name: lgc-app-data
|
||||
postgres-data:
|
||||
name: lgc-postgres-data
|
||||
redis-data:
|
||||
name: lgc-redis-data
|
||||
prometheus-data:
|
||||
name: lgc-prometheus-data
|
||||
grafana-data:
|
||||
name: lgc-grafana-data
|
||||
363
docs/DATABASE.md
Normal file
363
docs/DATABASE.md
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
# LocalGreenChain Database Integration
|
||||
|
||||
This document describes the PostgreSQL database integration for LocalGreenChain, implemented as part of Agent 2's deployment.
|
||||
|
||||
## Overview
|
||||
|
||||
The database layer provides persistent storage for all LocalGreenChain entities using PostgreSQL and Prisma ORM. This replaces the previous file-based JSON storage with a robust, scalable database solution.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
### 2. Configure Database
|
||||
|
||||
Copy the environment template and configure your database connection:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your PostgreSQL connection string:
|
||||
|
||||
```env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/localgreenchain?schema=public"
|
||||
```
|
||||
|
||||
### 3. Generate Prisma Client
|
||||
|
||||
```bash
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
### 4. Run Migrations
|
||||
|
||||
For development:
|
||||
```bash
|
||||
bun run db:migrate
|
||||
```
|
||||
|
||||
For production:
|
||||
```bash
|
||||
bun run db:migrate:prod
|
||||
```
|
||||
|
||||
### 5. Seed Database (Optional)
|
||||
|
||||
```bash
|
||||
bun run db:seed
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
The schema includes the following main entities:
|
||||
|
||||
### Core Entities
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `User` | User accounts with authentication and profiles |
|
||||
| `Plant` | Plant records with lineage tracking |
|
||||
| `TransportEvent` | Supply chain transport events |
|
||||
| `SeedBatch` | Seed batch tracking |
|
||||
| `HarvestBatch` | Harvest batch records |
|
||||
|
||||
### Vertical Farming
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `VerticalFarm` | Vertical farm facilities |
|
||||
| `GrowingZone` | Individual growing zones |
|
||||
| `CropBatch` | Active crop batches |
|
||||
| `GrowingRecipe` | Growing recipes/protocols |
|
||||
| `ResourceUsage` | Energy and resource tracking |
|
||||
| `FarmAnalytics` | Farm performance analytics |
|
||||
|
||||
### Demand & Market
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `ConsumerPreference` | Consumer food preferences |
|
||||
| `DemandSignal` | Aggregated demand signals |
|
||||
| `SupplyCommitment` | Grower supply commitments |
|
||||
| `MarketMatch` | Matched supply and demand |
|
||||
| `SeasonalPlan` | Grower seasonal plans |
|
||||
| `DemandForecast` | Demand predictions |
|
||||
| `PlantingRecommendation` | Planting recommendations |
|
||||
|
||||
### Audit & Blockchain
|
||||
|
||||
| Entity | Description |
|
||||
|--------|-------------|
|
||||
| `AuditLog` | System audit trail |
|
||||
| `BlockchainBlock` | Blockchain block storage |
|
||||
|
||||
## Usage
|
||||
|
||||
### Importing the Database Layer
|
||||
|
||||
```typescript
|
||||
import * as db from '@/lib/db';
|
||||
// or import specific functions
|
||||
import { createPlant, getPlantById, getPlantLineage } from '@/lib/db';
|
||||
```
|
||||
|
||||
### Common Operations
|
||||
|
||||
#### Users
|
||||
|
||||
```typescript
|
||||
// Create a user
|
||||
const user = await db.createUser({
|
||||
email: 'grower@example.com',
|
||||
name: 'John Farmer',
|
||||
userType: 'GROWER',
|
||||
city: 'San Francisco',
|
||||
country: 'USA',
|
||||
});
|
||||
|
||||
// Get user by email
|
||||
const user = await db.getUserByEmail('grower@example.com');
|
||||
|
||||
// Get user with their plants
|
||||
const userWithPlants = await db.getUserWithPlants(userId);
|
||||
```
|
||||
|
||||
#### Plants
|
||||
|
||||
```typescript
|
||||
// Create a plant
|
||||
const plant = await db.createPlant({
|
||||
commonName: 'Cherry Tomato',
|
||||
scientificName: 'Solanum lycopersicum',
|
||||
plantedDate: new Date(),
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
ownerId: userId,
|
||||
});
|
||||
|
||||
// Clone a plant
|
||||
const clone = await db.clonePlant(parentId, newOwnerId, 'CLONE');
|
||||
|
||||
// Get plant lineage
|
||||
const lineage = await db.getPlantLineage(plantId);
|
||||
// Returns: { plant, ancestors, descendants, siblings }
|
||||
|
||||
// Find nearby plants
|
||||
const nearby = await db.getNearbyPlants({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
radiusKm: 10,
|
||||
});
|
||||
```
|
||||
|
||||
#### Transport Events
|
||||
|
||||
```typescript
|
||||
// Create a transport event
|
||||
const event = await db.createTransportEvent({
|
||||
eventType: 'DISTRIBUTION',
|
||||
fromLatitude: 37.8044,
|
||||
fromLongitude: -122.2712,
|
||||
fromLocationType: 'VERTICAL_FARM',
|
||||
toLatitude: 37.7849,
|
||||
toLongitude: -122.4094,
|
||||
toLocationType: 'MARKET',
|
||||
durationMinutes: 25,
|
||||
transportMethod: 'ELECTRIC_TRUCK',
|
||||
senderId: farmerId,
|
||||
receiverId: distributorId,
|
||||
});
|
||||
|
||||
// Get plant journey
|
||||
const journey = await db.getPlantJourney(plantId);
|
||||
|
||||
// Get environmental impact
|
||||
const impact = await db.getEnvironmentalImpact({ userId });
|
||||
```
|
||||
|
||||
#### Vertical Farms
|
||||
|
||||
```typescript
|
||||
// Create a farm
|
||||
const farm = await db.createVerticalFarm({
|
||||
name: 'Urban Greens',
|
||||
ownerId: userId,
|
||||
latitude: 37.8044,
|
||||
longitude: -122.2712,
|
||||
address: '123 Farm St',
|
||||
city: 'Oakland',
|
||||
country: 'USA',
|
||||
specs: { totalAreaSqm: 500, numberOfLevels: 5 },
|
||||
});
|
||||
|
||||
// Create a growing zone
|
||||
const zone = await db.createGrowingZone({
|
||||
name: 'Zone A',
|
||||
farmId: farm.id,
|
||||
level: 1,
|
||||
areaSqm: 80,
|
||||
growingMethod: 'NFT',
|
||||
plantPositions: 400,
|
||||
});
|
||||
|
||||
// Create a crop batch
|
||||
const batch = await db.createCropBatch({
|
||||
farmId: farm.id,
|
||||
zoneId: zone.id,
|
||||
cropType: 'Lettuce',
|
||||
plantCount: 400,
|
||||
plantingDate: new Date(),
|
||||
expectedHarvestDate: new Date(Date.now() + 28 * 24 * 60 * 60 * 1000),
|
||||
expectedYieldKg: 60,
|
||||
});
|
||||
```
|
||||
|
||||
#### Demand & Market
|
||||
|
||||
```typescript
|
||||
// Set consumer preferences
|
||||
await db.upsertConsumerPreference({
|
||||
consumerId: userId,
|
||||
latitude: 37.7849,
|
||||
longitude: -122.4094,
|
||||
preferredCategories: ['leafy_greens', 'herbs'],
|
||||
certificationPreferences: ['organic', 'local'],
|
||||
});
|
||||
|
||||
// Create supply commitment
|
||||
const commitment = await db.createSupplyCommitment({
|
||||
growerId: farmerId,
|
||||
produceType: 'Butterhead Lettuce',
|
||||
committedQuantityKg: 60,
|
||||
availableFrom: new Date(),
|
||||
availableUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
pricePerKg: 8.5,
|
||||
deliveryRadiusKm: 25,
|
||||
deliveryMethods: ['grower_delivery', 'customer_pickup'],
|
||||
});
|
||||
|
||||
// Find matching supply
|
||||
const matches = await db.findMatchingSupply(
|
||||
'Lettuce',
|
||||
{ latitude: 37.7849, longitude: -122.4094, radiusKm: 20 },
|
||||
new Date()
|
||||
);
|
||||
```
|
||||
|
||||
### Using the Database-Backed Blockchain
|
||||
|
||||
The database layer includes a `PlantChainDB` class that provides blockchain functionality with PostgreSQL persistence:
|
||||
|
||||
```typescript
|
||||
import { getBlockchainDB } from '@/lib/blockchain/manager';
|
||||
|
||||
// Get the database-backed blockchain
|
||||
const chain = await getBlockchainDB();
|
||||
|
||||
// Register a plant (creates both DB record and blockchain block)
|
||||
const { plant, block } = await chain.registerPlant({
|
||||
commonName: 'Tomato',
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
ownerId: userId,
|
||||
});
|
||||
|
||||
// Clone a plant
|
||||
const { plant: clone, block: cloneBlock } = await chain.clonePlant(
|
||||
parentId,
|
||||
newOwnerId,
|
||||
'CLONE'
|
||||
);
|
||||
|
||||
// Verify blockchain integrity
|
||||
const isValid = await chain.isChainValid();
|
||||
```
|
||||
|
||||
## NPM Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `bun run db:generate` | Generate Prisma client |
|
||||
| `bun run db:push` | Push schema to database (dev) |
|
||||
| `bun run db:migrate` | Create and run migration (dev) |
|
||||
| `bun run db:migrate:prod` | Run migrations (production) |
|
||||
| `bun run db:seed` | Seed database with test data |
|
||||
| `bun run db:studio` | Open Prisma Studio GUI |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
lib/db/
|
||||
├── prisma.ts # Prisma client singleton
|
||||
├── types.ts # Type definitions and utilities
|
||||
├── users.ts # User CRUD operations
|
||||
├── plants.ts # Plant operations with lineage
|
||||
├── transport.ts # Transport event operations
|
||||
├── farms.ts # Vertical farm operations
|
||||
├── demand.ts # Demand and market operations
|
||||
├── audit.ts # Audit logging and blockchain
|
||||
└── index.ts # Central exports
|
||||
|
||||
prisma/
|
||||
├── schema.prisma # Database schema
|
||||
└── seed.ts # Seed script
|
||||
```
|
||||
|
||||
## Migration from File Storage
|
||||
|
||||
If you have existing data in JSON files, you can migrate to the database:
|
||||
|
||||
1. Ensure database is configured and migrations are run
|
||||
2. Load existing JSON data
|
||||
3. Use the database service layer to insert records
|
||||
4. Verify data integrity
|
||||
5. Remove old JSON files
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- The schema includes strategic indexes on frequently queried fields
|
||||
- Pagination is supported for large result sets
|
||||
- Location-based queries use in-memory filtering (consider PostGIS for large scale)
|
||||
- Blockchain integrity verification scans all blocks (cache results for performance)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
```bash
|
||||
# Test database connection
|
||||
bunx prisma db pull
|
||||
```
|
||||
|
||||
### Migration Issues
|
||||
|
||||
```bash
|
||||
# Reset database (WARNING: deletes all data)
|
||||
bunx prisma migrate reset
|
||||
|
||||
# Generate new migration
|
||||
bunx prisma migrate dev --name your_migration_name
|
||||
```
|
||||
|
||||
### Type Issues
|
||||
|
||||
```bash
|
||||
# Regenerate Prisma client
|
||||
bun run db:generate
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit `.env` files with real credentials
|
||||
- Use environment variables for all sensitive configuration
|
||||
- Database user should have minimal required permissions
|
||||
- Enable SSL for production database connections
|
||||
|
||||
---
|
||||
|
||||
*Implemented by Agent 2 - Database Integration*
|
||||
682
infra/grafana/dashboards/localgreenchain.json
Normal file
682
infra/grafana/dashboards/localgreenchain.json
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {
|
||||
"type": "grafana",
|
||||
"uid": "-- Grafana --"
|
||||
},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "LocalGreenChain Application Dashboard",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 0,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
"id": 1,
|
||||
"panels": [],
|
||||
"title": "Overview",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 0,
|
||||
"y": 1
|
||||
},
|
||||
"id": 2,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "sum(lgc_http_requests_total)",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Total Requests",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 6,
|
||||
"y": 1
|
||||
},
|
||||
"id": 3,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "lgc_plants_registered_total",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Plants Registered",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 12,
|
||||
"y": 1
|
||||
},
|
||||
"id": 4,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "lgc_active_agents",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Active Agents",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 4,
|
||||
"w": 6,
|
||||
"x": 18,
|
||||
"y": 1
|
||||
},
|
||||
"id": 5,
|
||||
"options": {
|
||||
"colorMode": "value",
|
||||
"graphMode": "area",
|
||||
"justifyMode": "auto",
|
||||
"orientation": "auto",
|
||||
"reduceOptions": {
|
||||
"calcs": ["lastNotNull"],
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"textMode": "auto"
|
||||
},
|
||||
"pluginVersion": "10.1.0",
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "lgc_blockchain_blocks",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Blockchain Blocks",
|
||||
"type": "stat"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 5
|
||||
},
|
||||
"id": 6,
|
||||
"panels": [],
|
||||
"title": "HTTP Metrics",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "reqps"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 6
|
||||
},
|
||||
"id": 7,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "rate(lgc_http_requests_total[5m])",
|
||||
"legendFormat": "{{method}} {{path}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Request Rate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 6
|
||||
},
|
||||
"id": 8,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "histogram_quantile(0.95, rate(lgc_http_request_duration_seconds_bucket[5m]))",
|
||||
"legendFormat": "p95 {{method}} {{path}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Request Duration (p95)",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"collapsed": false,
|
||||
"gridPos": {
|
||||
"h": 1,
|
||||
"w": 24,
|
||||
"x": 0,
|
||||
"y": 14
|
||||
},
|
||||
"id": 9,
|
||||
"panels": [],
|
||||
"title": "Agent Metrics",
|
||||
"type": "row"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 0,
|
||||
"y": 15
|
||||
},
|
||||
"id": 10,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "rate(lgc_agent_cycles_total[5m])",
|
||||
"legendFormat": "{{agent}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Agent Cycle Rate",
|
||||
"type": "timeseries"
|
||||
},
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 10,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "never",
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"mappings": [],
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": null
|
||||
}
|
||||
]
|
||||
},
|
||||
"unit": "s"
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"gridPos": {
|
||||
"h": 8,
|
||||
"w": 12,
|
||||
"x": 12,
|
||||
"y": 15
|
||||
},
|
||||
"id": 11,
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"mode": "multi",
|
||||
"sort": "none"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {
|
||||
"type": "prometheus",
|
||||
"uid": "${DS_PROMETHEUS}"
|
||||
},
|
||||
"expr": "histogram_quantile(0.95, rate(lgc_agent_cycle_duration_seconds_bucket[5m]))",
|
||||
"legendFormat": "{{agent}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Agent Cycle Duration (p95)",
|
||||
"type": "timeseries"
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["localgreenchain", "application"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {
|
||||
"selected": false,
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Datasource",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "LocalGreenChain Dashboard",
|
||||
"uid": "localgreenchain-main",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
16
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
16
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
# LocalGreenChain Grafana Dashboard Provisioning
|
||||
# Agent 4: Production Deployment
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: 'LocalGreenChain'
|
||||
orgId: 1
|
||||
folder: 'LocalGreenChain'
|
||||
folderUid: 'lgc'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 30
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: /var/lib/grafana/dashboards
|
||||
30
infra/grafana/provisioning/datasources/datasources.yml
Normal file
30
infra/grafana/provisioning/datasources/datasources.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# LocalGreenChain Grafana Datasources
|
||||
# Agent 4: Production Deployment
|
||||
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
# Prometheus
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
editable: false
|
||||
jsonData:
|
||||
timeInterval: "15s"
|
||||
httpMethod: POST
|
||||
|
||||
# PostgreSQL (optional)
|
||||
# - name: PostgreSQL
|
||||
# type: postgres
|
||||
# url: postgres:5432
|
||||
# database: localgreenchain
|
||||
# user: lgc
|
||||
# secureJsonData:
|
||||
# password: ${DB_PASSWORD}
|
||||
# jsonData:
|
||||
# sslmode: disable
|
||||
# maxOpenConns: 5
|
||||
# maxIdleConns: 2
|
||||
# connMaxLifetime: 14400
|
||||
65
infra/prometheus/prometheus.yml
Normal file
65
infra/prometheus/prometheus.yml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# LocalGreenChain Prometheus Configuration
|
||||
# Agent 4: Production Deployment
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
monitor: 'localgreenchain'
|
||||
|
||||
# Alerting configuration (optional)
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: []
|
||||
# - alertmanager:9093
|
||||
|
||||
# Rule files (optional)
|
||||
rule_files: []
|
||||
# - "first_rules.yml"
|
||||
# - "second_rules.yml"
|
||||
|
||||
# Scrape configurations
|
||||
scrape_configs:
|
||||
# Prometheus self-monitoring
|
||||
- job_name: 'prometheus'
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
metrics_path: /metrics
|
||||
|
||||
# LocalGreenChain Application
|
||||
- job_name: 'localgreenchain'
|
||||
static_configs:
|
||||
- targets: ['app:3001']
|
||||
metrics_path: /api/metrics
|
||||
scrape_interval: 30s
|
||||
scrape_timeout: 10s
|
||||
|
||||
# PostgreSQL (if using postgres_exporter)
|
||||
- job_name: 'postgresql'
|
||||
static_configs:
|
||||
- targets: []
|
||||
# - postgres-exporter:9187
|
||||
scrape_interval: 60s
|
||||
|
||||
# Redis (if using redis_exporter)
|
||||
- job_name: 'redis'
|
||||
static_configs:
|
||||
- targets: []
|
||||
# - redis-exporter:9121
|
||||
scrape_interval: 30s
|
||||
|
||||
# Node Exporter (if running)
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: []
|
||||
# - node-exporter:9100
|
||||
scrape_interval: 30s
|
||||
|
||||
# Remote write configuration (optional)
|
||||
# For long-term storage or external Prometheus
|
||||
# remote_write:
|
||||
# - url: "https://remote-prometheus.example.com/api/v1/write"
|
||||
# basic_auth:
|
||||
# username: user
|
||||
# password: pass
|
||||
|
|
@ -8,14 +8,20 @@ const config = {
|
|||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': ['ts-jest', {
|
||||
tsconfig: 'tsconfig.json',
|
||||
}],
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: 'tsconfig.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'lib/**/*.ts',
|
||||
'!lib/**/*.d.ts',
|
||||
'!lib/**/types.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html', 'json-summary'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
|
|
@ -24,8 +30,11 @@ const config = {
|
|||
statements: 80,
|
||||
},
|
||||
},
|
||||
setupFilesAfterEnv: [],
|
||||
setupFilesAfterEnv: ['<rootDir>/__tests__/setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/.next/', '/cypress/'],
|
||||
verbose: true,
|
||||
// Increase timeout for async tests
|
||||
testTimeout: 10000,
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ export class AgentOrchestrator {
|
|||
}
|
||||
|
||||
// Stop all agents
|
||||
for (const agent of this.agents.values()) {
|
||||
for (const agent of Array.from(this.agents.values())) {
|
||||
try {
|
||||
await agent.stop();
|
||||
console.log(`[Orchestrator] Stopped: ${agent.config.name}`);
|
||||
|
|
@ -275,7 +275,7 @@ export class AgentOrchestrator {
|
|||
* Perform health check on all agents
|
||||
*/
|
||||
private performHealthCheck(): void {
|
||||
for (const [agentId, agent] of this.agents) {
|
||||
for (const [agentId, agent] of Array.from(this.agents.entries())) {
|
||||
const health = this.getAgentHealth(agentId);
|
||||
|
||||
if (!health.isHealthy) {
|
||||
|
|
@ -296,7 +296,7 @@ export class AgentOrchestrator {
|
|||
private aggregateAlerts(): void {
|
||||
this.aggregatedAlerts = [];
|
||||
|
||||
for (const agent of this.agents.values()) {
|
||||
for (const agent of Array.from(this.agents.values())) {
|
||||
const alerts = agent.getAlerts()
|
||||
.filter(a => !a.acknowledged)
|
||||
.slice(-this.config.maxAlertsPerAgent);
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
*/
|
||||
async runOnce(): Promise<AgentTask | null> {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
const chain = blockchain.chain;
|
||||
const plants = chain.slice(1); // Skip genesis
|
||||
|
||||
let profilesUpdated = 0;
|
||||
|
|
@ -265,9 +265,9 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
for (const block of healthyPlants) {
|
||||
const env = block.plant.environment;
|
||||
if (env?.soil?.pH) pHValues.push(env.soil.pH);
|
||||
if (env?.climate?.avgTemperature) tempValues.push(env.climate.avgTemperature);
|
||||
if (env?.climate?.avgHumidity) humidityValues.push(env.climate.avgHumidity);
|
||||
if (env?.lighting?.hoursPerDay) lightValues.push(env.lighting.hoursPerDay);
|
||||
if (env?.climate?.temperatureDay) tempValues.push(env.climate.temperatureDay);
|
||||
if (env?.climate?.humidityAverage) humidityValues.push(env.climate.humidityAverage);
|
||||
if (env?.lighting?.naturalLight?.hoursPerDay) lightValues.push(env.lighting.naturalLight.hoursPerDay);
|
||||
}
|
||||
|
||||
const profile: EnvironmentProfile = existing || {
|
||||
|
|
@ -357,15 +357,16 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
|
||||
// Lighting analysis
|
||||
if (env.lighting) {
|
||||
const lightDiff = env.lighting.hoursPerDay
|
||||
? Math.abs(env.lighting.hoursPerDay - profile.optimalConditions.lightHours.optimal)
|
||||
const lightHours = env.lighting.naturalLight?.hoursPerDay || env.lighting.artificialLight?.hoursPerDay;
|
||||
const lightDiff = lightHours
|
||||
? Math.abs(lightHours - profile.optimalConditions.lightHours.optimal)
|
||||
: 2;
|
||||
lightingScore = Math.max(0, 100 - lightDiff * 15);
|
||||
|
||||
if (lightDiff > 2) {
|
||||
improvements.push({
|
||||
category: 'lighting',
|
||||
currentState: `${env.lighting.hoursPerDay || 'unknown'} hours/day`,
|
||||
currentState: `${lightHours || 'unknown'} hours/day`,
|
||||
recommendedState: `${profile.optimalConditions.lightHours.optimal} hours/day`,
|
||||
priority: lightDiff > 4 ? 'high' : 'medium',
|
||||
expectedImpact: 'Better photosynthesis and growth',
|
||||
|
|
@ -376,11 +377,11 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
|
||||
// Climate analysis
|
||||
if (env.climate) {
|
||||
const tempDiff = env.climate.avgTemperature
|
||||
? Math.abs(env.climate.avgTemperature - profile.optimalConditions.temperature.optimal)
|
||||
const tempDiff = env.climate.temperatureDay
|
||||
? Math.abs(env.climate.temperatureDay - profile.optimalConditions.temperature.optimal)
|
||||
: 5;
|
||||
const humDiff = env.climate.avgHumidity
|
||||
? Math.abs(env.climate.avgHumidity - profile.optimalConditions.humidity.optimal)
|
||||
const humDiff = env.climate.humidityAverage
|
||||
? Math.abs(env.climate.humidityAverage - profile.optimalConditions.humidity.optimal)
|
||||
: 10;
|
||||
|
||||
climateScore = Math.max(0, 100 - tempDiff * 5 - humDiff * 1);
|
||||
|
|
@ -388,7 +389,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
if (tempDiff > 3) {
|
||||
improvements.push({
|
||||
category: 'climate',
|
||||
currentState: `${env.climate.avgTemperature?.toFixed(1) || 'unknown'}°C`,
|
||||
currentState: `${env.climate.temperatureDay?.toFixed(1) || 'unknown'}°C`,
|
||||
recommendedState: `${profile.optimalConditions.temperature.optimal}°C`,
|
||||
priority: tempDiff > 6 ? 'high' : 'medium',
|
||||
expectedImpact: 'Reduced stress and improved growth',
|
||||
|
|
@ -408,7 +409,8 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
// Nutrients analysis
|
||||
if (env.nutrients) {
|
||||
nutrientsScore = 75; // Base score if nutrient data exists
|
||||
if (env.nutrients.fertilizer?.schedule === 'regular') {
|
||||
// Bonus for complete NPK profile
|
||||
if (env.nutrients.nitrogen && env.nutrients.phosphorus && env.nutrients.potassium) {
|
||||
nutrientsScore = 90;
|
||||
}
|
||||
}
|
||||
|
|
@ -462,7 +464,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
|
||||
// Find common soil types
|
||||
const soilTypes = plantsWithEnv
|
||||
.map(p => p.plant.environment?.soil?.soilType)
|
||||
.map(p => p.plant.environment?.soil?.type)
|
||||
.filter(Boolean);
|
||||
|
||||
const commonSoilType = this.findMostCommon(soilTypes as string[]);
|
||||
|
|
@ -471,7 +473,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
patterns.push({
|
||||
patternId: `pattern-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
||||
species,
|
||||
conditions: { soil: { soilType: commonSoilType } } as any,
|
||||
conditions: { soil: { type: commonSoilType } } as any,
|
||||
successMetric: 'health',
|
||||
successValue: 85,
|
||||
sampleSize: plantsWithEnv.length,
|
||||
|
|
@ -527,7 +529,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
if (cached) return cached;
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
const chain = blockchain.chain;
|
||||
|
||||
const block1 = chain.find(b => b.plant.id === plant1Id);
|
||||
const block2 = chain.find(b => b.plant.id === plant2Id);
|
||||
|
|
@ -545,14 +547,14 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
// Compare soil
|
||||
if (env1?.soil && env2?.soil) {
|
||||
totalFactors++;
|
||||
if (env1.soil.soilType === env2.soil.soilType) {
|
||||
if (env1.soil.type === env2.soil.type) {
|
||||
matchingFactors.push('Soil type');
|
||||
matchScore++;
|
||||
} else {
|
||||
differingFactors.push({
|
||||
factor: 'Soil type',
|
||||
plant1Value: env1.soil.soilType,
|
||||
plant2Value: env2.soil.soilType
|
||||
plant1Value: env1.soil.type,
|
||||
plant2Value: env2.soil.type
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -588,7 +590,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
if (env1?.climate && env2?.climate) {
|
||||
totalFactors++;
|
||||
const tempDiff = Math.abs(
|
||||
(env1.climate.avgTemperature || 0) - (env2.climate.avgTemperature || 0)
|
||||
(env1.climate.temperatureDay || 0) - (env2.climate.temperatureDay || 0)
|
||||
);
|
||||
if (tempDiff < 3) {
|
||||
matchingFactors.push('Temperature');
|
||||
|
|
@ -596,8 +598,8 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
} else {
|
||||
differingFactors.push({
|
||||
factor: 'Temperature',
|
||||
plant1Value: env1.climate.avgTemperature,
|
||||
plant2Value: env2.climate.avgTemperature
|
||||
plant1Value: env1.climate.temperatureDay,
|
||||
plant2Value: env2.climate.temperatureDay
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
|
|||
*/
|
||||
private updateGrowerProfiles(): void {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain().slice(1);
|
||||
const chain = blockchain.chain.slice(1);
|
||||
|
||||
const ownerPlants = new Map<string, typeof chain>();
|
||||
|
||||
|
|
@ -219,7 +219,11 @@ export class GrowerAdvisoryAgent extends BaseAgent {
|
|||
if (['growing', 'mature', 'flowering', 'fruiting'].includes(plant.plant.status)) {
|
||||
existing.healthy++;
|
||||
}
|
||||
existing.yield += plant.plant.growthMetrics?.estimatedYieldKg || 2;
|
||||
// Estimate yield based on health score, or use default of 2kg
|
||||
const healthMultiplier = plant.plant.growthMetrics?.healthScore
|
||||
? plant.plant.growthMetrics.healthScore / 50
|
||||
: 1;
|
||||
existing.yield += 2 * healthMultiplier;
|
||||
historyMap.set(crop, existing);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export class NetworkDiscoveryAgent extends BaseAgent {
|
|||
*/
|
||||
async runOnce(): Promise<AgentTask | null> {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
const chain = blockchain.chain;
|
||||
const plants = chain.slice(1);
|
||||
|
||||
// Build network from plant data
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export class PlantLineageAgent extends BaseAgent {
|
|||
*/
|
||||
async runOnce(): Promise<AgentTask | null> {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
const chain = blockchain.chain;
|
||||
|
||||
// Skip genesis block
|
||||
const plantBlocks = chain.slice(1);
|
||||
|
|
@ -133,7 +133,7 @@ export class PlantLineageAgent extends BaseAgent {
|
|||
totalLineageSize: ancestors.length + descendants.length + 1,
|
||||
propagationChain,
|
||||
geographicSpread,
|
||||
oldestAncestorDate: oldestAncestor?.timestamp || plant.dateAcquired,
|
||||
oldestAncestorDate: oldestAncestor?.timestamp || plant.registeredAt,
|
||||
healthScore: this.calculateHealthScore(plant, chain)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import { BaseAgent } from './BaseAgent';
|
|||
import { AgentConfig, AgentTask, QualityReport } from './types';
|
||||
import { getBlockchain } from '../blockchain/manager';
|
||||
import { getTransportChain } from '../transport/tracker';
|
||||
import { PlantBlock } from '../blockchain/types';
|
||||
import crypto from 'crypto';
|
||||
import { PlantBlock } from '../blockchain/PlantBlock';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
interface IntegrityCheck {
|
||||
chainId: string;
|
||||
|
|
@ -131,7 +131,7 @@ export class QualityAssuranceAgent extends BaseAgent {
|
|||
*/
|
||||
private async verifyPlantChain(): Promise<IntegrityCheck> {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain();
|
||||
const chain = blockchain.chain;
|
||||
|
||||
let hashMismatches = 0;
|
||||
let linkBroken = 0;
|
||||
|
|
@ -205,7 +205,7 @@ export class QualityAssuranceAgent extends BaseAgent {
|
|||
const issues: DataQualityIssue[] = [];
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain().slice(1);
|
||||
const chain = blockchain.chain.slice(1);
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
|
|
@ -390,7 +390,7 @@ export class QualityAssuranceAgent extends BaseAgent {
|
|||
*/
|
||||
private calculateStatistics(): DataStatistics {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.getChain().slice(1);
|
||||
const chain = blockchain.chain.slice(1);
|
||||
|
||||
let completeRecords = 0;
|
||||
let partialRecords = 0;
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ export class SustainabilityAgent extends BaseAgent {
|
|||
*/
|
||||
private calculateWaterMetrics(): WaterMetrics {
|
||||
const blockchain = getBlockchain();
|
||||
const plantCount = blockchain.getChain().length - 1;
|
||||
const plantCount = blockchain.chain.length - 1;
|
||||
|
||||
// Simulate water usage based on plant count
|
||||
// Vertical farms use ~10% of traditional water
|
||||
|
|
@ -265,7 +265,7 @@ export class SustainabilityAgent extends BaseAgent {
|
|||
*/
|
||||
private calculateWasteMetrics(): WasteMetrics {
|
||||
const blockchain = getBlockchain();
|
||||
const plants = blockchain.getChain().slice(1);
|
||||
const plants = blockchain.chain.slice(1);
|
||||
|
||||
const deceasedPlants = plants.filter(p => p.plant.status === 'deceased').length;
|
||||
const totalPlants = plants.length;
|
||||
|
|
@ -311,7 +311,7 @@ export class SustainabilityAgent extends BaseAgent {
|
|||
|
||||
// Biodiversity: based on plant variety
|
||||
const blockchain = getBlockchain();
|
||||
const plants = blockchain.getChain().slice(1);
|
||||
const plants = blockchain.chain.slice(1);
|
||||
const uniqueSpecies = new Set(plants.map(p => p.plant.commonName)).size;
|
||||
const biodiversity = Math.min(100, 30 + uniqueSpecies * 5);
|
||||
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ export interface QualityReport {
|
|||
blockIndex: number;
|
||||
issueType: string;
|
||||
description: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
}[];
|
||||
lastVerifiedAt: string;
|
||||
}
|
||||
|
|
|
|||
406
lib/analytics/aggregator.ts
Normal file
406
lib/analytics/aggregator.ts
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
/**
|
||||
* Data Aggregator for Analytics
|
||||
* Aggregates data from various sources for analytics dashboards
|
||||
*/
|
||||
|
||||
import {
|
||||
AnalyticsOverview,
|
||||
PlantAnalytics,
|
||||
TransportAnalytics,
|
||||
FarmAnalytics,
|
||||
SustainabilityAnalytics,
|
||||
TimeRange,
|
||||
DateRange,
|
||||
TimeSeriesDataPoint,
|
||||
AnalyticsFilters,
|
||||
AggregationConfig,
|
||||
GroupByPeriod,
|
||||
} from './types';
|
||||
import { subDays, subMonths, startOfDay, endOfDay, format, eachDayOfInterval, parseISO } from 'date-fns';
|
||||
|
||||
// Mock data generators for demonstration - in production these would query actual databases
|
||||
|
||||
/**
|
||||
* Get date range from TimeRange enum
|
||||
*/
|
||||
export function getDateRangeFromTimeRange(timeRange: TimeRange): DateRange {
|
||||
const end = endOfDay(new Date());
|
||||
let start: Date;
|
||||
|
||||
switch (timeRange) {
|
||||
case '7d':
|
||||
start = startOfDay(subDays(new Date(), 7));
|
||||
break;
|
||||
case '30d':
|
||||
start = startOfDay(subDays(new Date(), 30));
|
||||
break;
|
||||
case '90d':
|
||||
start = startOfDay(subDays(new Date(), 90));
|
||||
break;
|
||||
case '365d':
|
||||
start = startOfDay(subDays(new Date(), 365));
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
start = startOfDay(subMonths(new Date(), 24)); // Default to 2 years
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate time series data points for a date range
|
||||
*/
|
||||
export function generateTimeSeriesPoints(
|
||||
dateRange: DateRange,
|
||||
valueGenerator: (date: Date, index: number) => number
|
||||
): TimeSeriesDataPoint[] {
|
||||
const days = eachDayOfInterval({ start: dateRange.start, end: dateRange.end });
|
||||
return days.map((day, index) => ({
|
||||
timestamp: format(day, 'yyyy-MM-dd'),
|
||||
value: valueGenerator(day, index),
|
||||
label: format(day, 'MMM d'),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate data by time period
|
||||
*/
|
||||
export function aggregateByPeriod<T>(
|
||||
data: T[],
|
||||
dateField: keyof T,
|
||||
valueField: keyof T,
|
||||
period: GroupByPeriod
|
||||
): Record<string, number> {
|
||||
const aggregated: Record<string, number> = {};
|
||||
|
||||
data.forEach((item) => {
|
||||
const date = parseISO(item[dateField] as string);
|
||||
let key: string;
|
||||
|
||||
switch (period) {
|
||||
case 'hour':
|
||||
key = format(date, 'yyyy-MM-dd HH:00');
|
||||
break;
|
||||
case 'day':
|
||||
key = format(date, 'yyyy-MM-dd');
|
||||
break;
|
||||
case 'week':
|
||||
key = format(date, "yyyy-'W'ww");
|
||||
break;
|
||||
case 'month':
|
||||
key = format(date, 'yyyy-MM');
|
||||
break;
|
||||
case 'year':
|
||||
key = format(date, 'yyyy');
|
||||
break;
|
||||
}
|
||||
|
||||
aggregated[key] = (aggregated[key] || 0) + (item[valueField] as number);
|
||||
});
|
||||
|
||||
return aggregated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage change between two values
|
||||
*/
|
||||
export function calculateChange(current: number, previous: number): { change: number; percent: number } {
|
||||
const change = current - previous;
|
||||
const percent = previous !== 0 ? (change / previous) * 100 : current > 0 ? 100 : 0;
|
||||
return { change, percent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics overview with aggregated metrics
|
||||
*/
|
||||
export async function getAnalyticsOverview(
|
||||
filters: AnalyticsFilters = { timeRange: '30d' }
|
||||
): Promise<AnalyticsOverview> {
|
||||
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
|
||||
|
||||
// In production, these would be actual database queries
|
||||
// For now, generate realistic mock data
|
||||
const baseValue = 1000 + Math.random() * 500;
|
||||
|
||||
return {
|
||||
totalPlants: Math.floor(baseValue * 1.5),
|
||||
plantsRegisteredToday: Math.floor(Math.random() * 15 + 5),
|
||||
plantsRegisteredThisWeek: Math.floor(Math.random() * 80 + 40),
|
||||
plantsRegisteredThisMonth: Math.floor(Math.random() * 250 + 150),
|
||||
totalTransportEvents: Math.floor(baseValue * 2.3),
|
||||
totalCarbonKg: Math.round((Math.random() * 500 + 200) * 100) / 100,
|
||||
totalFoodMiles: Math.round((Math.random() * 10000 + 5000) * 10) / 10,
|
||||
activeUsers: Math.floor(Math.random() * 200 + 100),
|
||||
growthRate: Math.round((Math.random() * 20 + 5) * 10) / 10,
|
||||
trendsData: [
|
||||
{
|
||||
metric: 'Plants',
|
||||
currentValue: Math.floor(baseValue * 1.5),
|
||||
previousValue: Math.floor(baseValue * 1.35),
|
||||
change: Math.floor(baseValue * 0.15),
|
||||
changePercent: 11.1,
|
||||
direction: 'up',
|
||||
period: filters.timeRange,
|
||||
},
|
||||
{
|
||||
metric: 'Carbon Saved',
|
||||
currentValue: Math.round((Math.random() * 200 + 100) * 10) / 10,
|
||||
previousValue: Math.round((Math.random() * 180 + 90) * 10) / 10,
|
||||
change: Math.round((Math.random() * 20 + 10) * 10) / 10,
|
||||
changePercent: 12.5,
|
||||
direction: 'up',
|
||||
period: filters.timeRange,
|
||||
},
|
||||
{
|
||||
metric: 'Active Users',
|
||||
currentValue: Math.floor(Math.random() * 200 + 100),
|
||||
previousValue: Math.floor(Math.random() * 180 + 90),
|
||||
change: Math.floor(Math.random() * 30 + 10),
|
||||
changePercent: 8.3,
|
||||
direction: 'up',
|
||||
period: filters.timeRange,
|
||||
},
|
||||
{
|
||||
metric: 'Food Miles',
|
||||
currentValue: Math.round((Math.random() * 5000 + 2500) * 10) / 10,
|
||||
previousValue: Math.round((Math.random() * 5500 + 2800) * 10) / 10,
|
||||
change: -Math.round((Math.random() * 500 + 200) * 10) / 10,
|
||||
changePercent: -8.7,
|
||||
direction: 'down',
|
||||
period: filters.timeRange,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plant-specific analytics
|
||||
*/
|
||||
export async function getPlantAnalytics(
|
||||
filters: AnalyticsFilters = { timeRange: '30d' }
|
||||
): Promise<PlantAnalytics> {
|
||||
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
|
||||
|
||||
const speciesData = [
|
||||
{ species: 'Tomato', count: 245, percentage: 28.5, trend: 'up' as const },
|
||||
{ species: 'Lettuce', count: 198, percentage: 23.0, trend: 'up' as const },
|
||||
{ species: 'Pepper', count: 156, percentage: 18.1, trend: 'stable' as const },
|
||||
{ species: 'Basil', count: 134, percentage: 15.6, trend: 'up' as const },
|
||||
{ species: 'Cucumber', count: 87, percentage: 10.1, trend: 'down' as const },
|
||||
{ species: 'Other', count: 41, percentage: 4.7, trend: 'stable' as const },
|
||||
];
|
||||
|
||||
return {
|
||||
totalPlants: speciesData.reduce((sum, s) => sum + s.count, 0),
|
||||
plantsBySpecies: speciesData,
|
||||
plantsByGeneration: [
|
||||
{ generation: 1, count: 340, percentage: 39.5 },
|
||||
{ generation: 2, count: 280, percentage: 32.5 },
|
||||
{ generation: 3, count: 156, percentage: 18.1 },
|
||||
{ generation: 4, count: 68, percentage: 7.9 },
|
||||
{ generation: 5, count: 17, percentage: 2.0 },
|
||||
],
|
||||
registrationsTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.floor(Math.random() * 15 + 5 + Math.sin(i / 7) * 5)
|
||||
),
|
||||
averageLineageDepth: 2.3,
|
||||
topGrowers: [
|
||||
{ userId: 'user-1', name: 'Green Gardens Co', totalPlants: 145, totalSpecies: 12, averageGeneration: 2.1 },
|
||||
{ userId: 'user-2', name: 'Urban Farm LLC', totalPlants: 98, totalSpecies: 8, averageGeneration: 1.8 },
|
||||
{ userId: 'user-3', name: 'Local Seeds Inc', totalPlants: 76, totalSpecies: 15, averageGeneration: 3.2 },
|
||||
],
|
||||
recentRegistrations: [
|
||||
{ id: 'plant-1', name: 'Cherry Tomato #245', species: 'Tomato', registeredAt: new Date().toISOString(), generation: 3 },
|
||||
{ id: 'plant-2', name: 'Butterhead Lettuce', species: 'Lettuce', registeredAt: new Date().toISOString(), generation: 2 },
|
||||
{ id: 'plant-3', name: 'Sweet Basil', species: 'Basil', registeredAt: new Date().toISOString(), generation: 1 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transport analytics
|
||||
*/
|
||||
export async function getTransportAnalytics(
|
||||
filters: AnalyticsFilters = { timeRange: '30d' }
|
||||
): Promise<TransportAnalytics> {
|
||||
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
|
||||
|
||||
return {
|
||||
totalEvents: 2847,
|
||||
totalDistanceKm: 15234.5,
|
||||
totalCarbonKg: 487.3,
|
||||
carbonSavedKg: 1256.8,
|
||||
eventsByType: [
|
||||
{ eventType: 'seed_acquisition', count: 423, percentage: 14.9, carbonKg: 52.3 },
|
||||
{ eventType: 'growing_transport', count: 687, percentage: 24.1, carbonKg: 112.4 },
|
||||
{ eventType: 'harvest', count: 534, percentage: 18.8, carbonKg: 45.2 },
|
||||
{ eventType: 'distribution', count: 756, percentage: 26.6, carbonKg: 178.9 },
|
||||
{ eventType: 'consumer_delivery', count: 447, percentage: 15.7, carbonKg: 98.5 },
|
||||
],
|
||||
eventsByMethod: [
|
||||
{ method: 'walking', count: 312, percentage: 11.0, distanceKm: 156, carbonKg: 0, efficiency: 100 },
|
||||
{ method: 'bicycle', count: 534, percentage: 18.8, distanceKm: 1602, carbonKg: 0, efficiency: 100 },
|
||||
{ method: 'electric_vehicle', count: 687, percentage: 24.1, distanceKm: 4806, carbonKg: 72.1, efficiency: 85 },
|
||||
{ method: 'gasoline_vehicle', count: 756, percentage: 26.6, distanceKm: 5292, carbonKg: 264.6, efficiency: 45 },
|
||||
{ method: 'local_delivery', count: 558, percentage: 19.6, distanceKm: 3378, carbonKg: 150.6, efficiency: 60 },
|
||||
],
|
||||
dailyStats: generateTimeSeriesPoints(dateRange, (_, i) => ({
|
||||
date: format(dateRange.start, 'yyyy-MM-dd'),
|
||||
eventCount: Math.floor(Math.random() * 80 + 40),
|
||||
distanceKm: Math.round((Math.random() * 500 + 200) * 10) / 10,
|
||||
carbonKg: Math.round((Math.random() * 20 + 5) * 100) / 100,
|
||||
})).map(p => ({
|
||||
date: p.timestamp,
|
||||
eventCount: p.value,
|
||||
distanceKm: Math.round((Math.random() * 500 + 200) * 10) / 10,
|
||||
carbonKg: Math.round((Math.random() * 20 + 5) * 100) / 100,
|
||||
})),
|
||||
averageDistancePerEvent: 5.35,
|
||||
mostEfficientRoutes: [
|
||||
{ from: 'Local Farm A', to: 'Community Center', method: 'bicycle', distanceKm: 2.3, carbonKg: 0, frequency: 45 },
|
||||
{ from: 'Urban Garden', to: 'Farmers Market', method: 'walking', distanceKm: 0.8, carbonKg: 0, frequency: 38 },
|
||||
{ from: 'Rooftop Farm', to: 'Restaurant Row', method: 'electric_vehicle', distanceKm: 4.5, carbonKg: 0.07, frequency: 32 },
|
||||
],
|
||||
carbonTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.round((Math.random() * 15 + 10 - i * 0.1) * 100) / 100
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get farm analytics
|
||||
*/
|
||||
export async function getFarmAnalytics(
|
||||
filters: AnalyticsFilters = { timeRange: '30d' }
|
||||
): Promise<FarmAnalytics> {
|
||||
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
|
||||
|
||||
return {
|
||||
totalFarms: 24,
|
||||
totalZones: 156,
|
||||
activeBatches: 89,
|
||||
completedBatches: 234,
|
||||
averageYieldKg: 45.6,
|
||||
resourceUsage: {
|
||||
waterLiters: 125000,
|
||||
energyKwh: 8500,
|
||||
nutrientsKg: 450,
|
||||
waterEfficiency: 87.5,
|
||||
energyEfficiency: 92.3,
|
||||
},
|
||||
performanceByZone: [
|
||||
{ zoneId: 'zone-1', zoneName: 'Zone A - Leafy Greens', currentCrop: 'Lettuce', healthScore: 94, yieldKg: 52.3, efficiency: 91 },
|
||||
{ zoneId: 'zone-2', zoneName: 'Zone B - Herbs', currentCrop: 'Basil', healthScore: 88, yieldKg: 38.7, efficiency: 85 },
|
||||
{ zoneId: 'zone-3', zoneName: 'Zone C - Tomatoes', currentCrop: 'Cherry Tomato', healthScore: 92, yieldKg: 67.4, efficiency: 89 },
|
||||
{ zoneId: 'zone-4', zoneName: 'Zone D - Microgreens', currentCrop: 'Mixed Micro', healthScore: 96, yieldKg: 24.1, efficiency: 94 },
|
||||
],
|
||||
batchCompletionTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.floor(Math.random() * 5 + 2)
|
||||
),
|
||||
yieldPredictions: [
|
||||
{ cropType: 'Lettuce', predictedYieldKg: 156.5, confidence: 0.92, harvestDate: format(subDays(new Date(), -7), 'yyyy-MM-dd') },
|
||||
{ cropType: 'Tomato', predictedYieldKg: 234.8, confidence: 0.87, harvestDate: format(subDays(new Date(), -14), 'yyyy-MM-dd') },
|
||||
{ cropType: 'Basil', predictedYieldKg: 45.2, confidence: 0.94, harvestDate: format(subDays(new Date(), -5), 'yyyy-MM-dd') },
|
||||
],
|
||||
topPerformingCrops: [
|
||||
{ cropType: 'Lettuce', averageYieldKg: 48.3, growthDays: 28, successRate: 94.5, batches: 45 },
|
||||
{ cropType: 'Basil', averageYieldKg: 12.4, growthDays: 21, successRate: 91.2, batches: 38 },
|
||||
{ cropType: 'Cherry Tomato', averageYieldKg: 67.8, growthDays: 65, successRate: 88.7, batches: 22 },
|
||||
{ cropType: 'Microgreens', averageYieldKg: 5.6, growthDays: 14, successRate: 96.8, batches: 67 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sustainability analytics
|
||||
*/
|
||||
export async function getSustainabilityAnalytics(
|
||||
filters: AnalyticsFilters = { timeRange: '30d' }
|
||||
): Promise<SustainabilityAnalytics> {
|
||||
const dateRange = filters.dateRange || getDateRangeFromTimeRange(filters.timeRange);
|
||||
|
||||
return {
|
||||
overallScore: 82.5,
|
||||
carbonFootprint: {
|
||||
totalEmittedKg: 487.3,
|
||||
totalSavedKg: 1256.8,
|
||||
netImpactKg: -769.5,
|
||||
reductionPercentage: 72.1,
|
||||
equivalentTrees: 38.4,
|
||||
monthlyTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.round((50 - i * 0.5 + Math.random() * 10) * 10) / 10
|
||||
),
|
||||
},
|
||||
foodMiles: {
|
||||
totalMiles: 15234.5,
|
||||
averageMilesPerPlant: 17.7,
|
||||
savedMiles: 48672.3,
|
||||
localPercentage: 76.2,
|
||||
monthlyTrend: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.round((600 - i * 5 + Math.random() * 100) * 10) / 10
|
||||
),
|
||||
},
|
||||
waterUsage: {
|
||||
totalUsedLiters: 125000,
|
||||
savedLiters: 87500,
|
||||
efficiencyScore: 87.5,
|
||||
perKgProduce: 2.8,
|
||||
},
|
||||
localProduction: {
|
||||
localCount: 654,
|
||||
totalCount: 861,
|
||||
percentage: 76.0,
|
||||
trend: 'up',
|
||||
},
|
||||
goals: [
|
||||
{ id: 'goal-1', name: 'Carbon Neutral by 2025', target: 0, current: 487.3, unit: 'kg CO2', progress: 72, deadline: '2025-12-31', status: 'on_track' },
|
||||
{ id: 'goal-2', name: '80% Local Production', target: 80, current: 76, unit: '%', progress: 95, deadline: '2024-12-31', status: 'on_track' },
|
||||
{ id: 'goal-3', name: 'Reduce Food Miles 50%', target: 50, current: 38, unit: '%', progress: 76, deadline: '2024-06-30', status: 'at_risk' },
|
||||
{ id: 'goal-4', name: 'Water Efficiency 90%', target: 90, current: 87.5, unit: '%', progress: 97, deadline: '2024-12-31', status: 'on_track' },
|
||||
],
|
||||
trends: [
|
||||
{
|
||||
metric: 'Carbon Reduction',
|
||||
values: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.round((65 + i * 0.3 + Math.random() * 5) * 10) / 10
|
||||
),
|
||||
},
|
||||
{
|
||||
metric: 'Local Production',
|
||||
values: generateTimeSeriesPoints(dateRange, (_, i) =>
|
||||
Math.round((70 + i * 0.2 + Math.random() * 3) * 10) / 10
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache management for analytics data
|
||||
*/
|
||||
const analyticsCache = new Map<string, { data: any; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export function getCachedData<T>(key: string): T | null {
|
||||
const cached = analyticsCache.get(key);
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setCachedData<T>(key: string, data: T): void {
|
||||
analyticsCache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
export function clearCache(): void {
|
||||
analyticsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key from filters
|
||||
*/
|
||||
export function generateCacheKey(prefix: string, filters: AnalyticsFilters): string {
|
||||
return `${prefix}-${JSON.stringify(filters)}`;
|
||||
}
|
||||
189
lib/analytics/cache.ts
Normal file
189
lib/analytics/cache.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Cache Management for Analytics
|
||||
* Provides caching for expensive analytics calculations
|
||||
*/
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AnalyticsCache {
|
||||
private cache: Map<string, CacheEntry<any>> = new Map();
|
||||
private defaultTTL: number = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get cached data
|
||||
*/
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cached data
|
||||
*/
|
||||
set<T>(key: string, data: T, ttlMs: number = this.defaultTTL): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if key exists and is valid
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific key
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear expired entries
|
||||
*/
|
||||
cleanup(): number {
|
||||
const now = Date.now();
|
||||
let cleaned = 0;
|
||||
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
cleaned++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats(): {
|
||||
size: number;
|
||||
validEntries: number;
|
||||
expiredEntries: number;
|
||||
} {
|
||||
const now = Date.now();
|
||||
let valid = 0;
|
||||
let expired = 0;
|
||||
|
||||
for (const entry of this.cache.values()) {
|
||||
if (now > entry.expiresAt) {
|
||||
expired++;
|
||||
} else {
|
||||
valid++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
validEntries: valid,
|
||||
expiredEntries: expired,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key from object
|
||||
*/
|
||||
static generateKey(prefix: string, params: Record<string, any>): string {
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map((key) => `${key}:${JSON.stringify(params[key])}`)
|
||||
.join('|');
|
||||
return `${prefix}:${sortedParams}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const analyticsCache = new AnalyticsCache();
|
||||
|
||||
/**
|
||||
* Cache decorator for async functions
|
||||
*/
|
||||
export function cached<T>(
|
||||
keyGenerator: (...args: any[]) => string,
|
||||
ttlMs: number = 5 * 60 * 1000
|
||||
) {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor
|
||||
) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const cacheKey = keyGenerator(...args);
|
||||
const cached = analyticsCache.get<T>(cacheKey);
|
||||
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const result = await originalMethod.apply(this, args);
|
||||
analyticsCache.set(cacheKey, result, ttlMs);
|
||||
return result;
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Higher-order function for caching async functions
|
||||
*/
|
||||
export function withCache<T, A extends any[]>(
|
||||
fn: (...args: A) => Promise<T>,
|
||||
keyGenerator: (...args: A) => string,
|
||||
ttlMs: number = 5 * 60 * 1000
|
||||
): (...args: A) => Promise<T> {
|
||||
return async (...args: A): Promise<T> => {
|
||||
const cacheKey = keyGenerator(...args);
|
||||
const cached = analyticsCache.get<T>(cacheKey);
|
||||
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const result = await fn(...args);
|
||||
analyticsCache.set(cacheKey, result, ttlMs);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
// Schedule periodic cleanup
|
||||
if (typeof setInterval !== 'undefined') {
|
||||
setInterval(() => {
|
||||
analyticsCache.cleanup();
|
||||
}, 60 * 1000); // Run cleanup every minute
|
||||
}
|
||||
70
lib/analytics/index.ts
Normal file
70
lib/analytics/index.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Analytics Module Index
|
||||
* Exports all analytics functionality
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Data aggregation
|
||||
export {
|
||||
getDateRangeFromTimeRange,
|
||||
generateTimeSeriesPoints,
|
||||
aggregateByPeriod,
|
||||
calculateChange,
|
||||
getAnalyticsOverview,
|
||||
getPlantAnalytics,
|
||||
getTransportAnalytics,
|
||||
getFarmAnalytics,
|
||||
getSustainabilityAnalytics,
|
||||
getCachedData,
|
||||
setCachedData,
|
||||
clearCache,
|
||||
generateCacheKey,
|
||||
} from './aggregator';
|
||||
|
||||
// Metrics calculations
|
||||
export {
|
||||
mean,
|
||||
median,
|
||||
standardDeviation,
|
||||
percentile,
|
||||
minMax,
|
||||
getTrendDirection,
|
||||
percentageChange,
|
||||
movingAverage,
|
||||
rateOfChange,
|
||||
normalize,
|
||||
cagr,
|
||||
efficiencyScore,
|
||||
carbonIntensity,
|
||||
foodMilesScore,
|
||||
sustainabilityScore,
|
||||
generateKPICards,
|
||||
calculateGrowthMetrics,
|
||||
detectAnomalies,
|
||||
correlationCoefficient,
|
||||
formatNumber,
|
||||
formatPercentage,
|
||||
} from './metrics';
|
||||
|
||||
// Trend analysis
|
||||
export {
|
||||
analyzeTrend,
|
||||
linearRegression,
|
||||
forecast,
|
||||
detectSeasonality,
|
||||
findPeaksAndValleys,
|
||||
calculateMomentum,
|
||||
exponentialSmoothing,
|
||||
generateTrendSummary,
|
||||
compareTimeSeries,
|
||||
getTrendConfidence,
|
||||
yearOverYearComparison,
|
||||
} from './trends';
|
||||
|
||||
// Cache management
|
||||
export {
|
||||
analyticsCache,
|
||||
withCache,
|
||||
} from './cache';
|
||||
326
lib/analytics/metrics.ts
Normal file
326
lib/analytics/metrics.ts
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
/**
|
||||
* Metrics Calculations for Analytics
|
||||
* Provides metric calculations and statistical functions
|
||||
*/
|
||||
|
||||
import { TrendDirection, TimeSeriesDataPoint, KPICardData } from './types';
|
||||
|
||||
/**
|
||||
* Calculate mean of an array of numbers
|
||||
*/
|
||||
export function mean(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
return values.reduce((sum, v) => sum + v, 0) / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate median of an array of numbers
|
||||
*/
|
||||
export function median(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate standard deviation
|
||||
*/
|
||||
export function standardDeviation(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const avg = mean(values);
|
||||
const squareDiffs = values.map(v => Math.pow(v - avg, 2));
|
||||
return Math.sqrt(mean(squareDiffs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentile
|
||||
*/
|
||||
export function percentile(values: number[], p: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = (p / 100) * (sorted.length - 1);
|
||||
const lower = Math.floor(index);
|
||||
const upper = Math.ceil(index);
|
||||
const weight = index - lower;
|
||||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate min and max
|
||||
*/
|
||||
export function minMax(values: number[]): { min: number; max: number } {
|
||||
if (values.length === 0) return { min: 0, max: 0 };
|
||||
return {
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine trend direction from two values
|
||||
*/
|
||||
export function getTrendDirection(current: number, previous: number, threshold: number = 0.5): TrendDirection {
|
||||
const change = ((current - previous) / Math.abs(previous || 1)) * 100;
|
||||
if (Math.abs(change) < threshold) return 'stable';
|
||||
return change > 0 ? 'up' : 'down';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage change
|
||||
*/
|
||||
export function percentageChange(current: number, previous: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return ((current - previous) / Math.abs(previous)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate moving average
|
||||
*/
|
||||
export function movingAverage(data: TimeSeriesDataPoint[], windowSize: number): TimeSeriesDataPoint[] {
|
||||
return data.map((point, index) => {
|
||||
const start = Math.max(0, index - windowSize + 1);
|
||||
const window = data.slice(start, index + 1);
|
||||
const avg = mean(window.map(p => p.value));
|
||||
return {
|
||||
...point,
|
||||
value: Math.round(avg * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rate of change (derivative)
|
||||
*/
|
||||
export function rateOfChange(data: TimeSeriesDataPoint[]): TimeSeriesDataPoint[] {
|
||||
return data.slice(1).map((point, index) => ({
|
||||
...point,
|
||||
value: point.value - data[index].value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize values to 0-100 range
|
||||
*/
|
||||
export function normalize(values: number[]): number[] {
|
||||
const { min, max } = minMax(values);
|
||||
const range = max - min;
|
||||
if (range === 0) return values.map(() => 50);
|
||||
return values.map(v => ((v - min) / range) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate compound annual growth rate (CAGR)
|
||||
*/
|
||||
export function cagr(startValue: number, endValue: number, years: number): number {
|
||||
if (startValue <= 0 || years <= 0) return 0;
|
||||
return (Math.pow(endValue / startValue, 1 / years) - 1) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate efficiency score
|
||||
*/
|
||||
export function efficiencyScore(actual: number, optimal: number): number {
|
||||
if (optimal === 0) return actual === 0 ? 100 : 0;
|
||||
return Math.min(100, (optimal / actual) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate carbon intensity (kg CO2 per km)
|
||||
*/
|
||||
export function carbonIntensity(carbonKg: number, distanceKm: number): number {
|
||||
if (distanceKm === 0) return 0;
|
||||
return carbonKg / distanceKm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate food miles score (0-100, lower is better)
|
||||
*/
|
||||
export function foodMilesScore(miles: number, maxMiles: number = 5000): number {
|
||||
if (miles >= maxMiles) return 0;
|
||||
return Math.round((1 - miles / maxMiles) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate sustainability composite score
|
||||
*/
|
||||
export function sustainabilityScore(
|
||||
carbonReduction: number,
|
||||
localPercentage: number,
|
||||
waterEfficiency: number,
|
||||
wasteReduction: number
|
||||
): number {
|
||||
const weights = {
|
||||
carbon: 0.35,
|
||||
local: 0.25,
|
||||
water: 0.25,
|
||||
waste: 0.15,
|
||||
};
|
||||
|
||||
return Math.round(
|
||||
carbonReduction * weights.carbon +
|
||||
localPercentage * weights.local +
|
||||
waterEfficiency * weights.water +
|
||||
wasteReduction * weights.waste
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate KPI card data from metrics
|
||||
*/
|
||||
export function generateKPICards(metrics: {
|
||||
plants: { current: number; previous: number };
|
||||
carbon: { current: number; previous: number };
|
||||
foodMiles: { current: number; previous: number };
|
||||
users: { current: number; previous: number };
|
||||
sustainability: { current: number; previous: number };
|
||||
}): KPICardData[] {
|
||||
return [
|
||||
{
|
||||
id: 'total-plants',
|
||||
title: 'Total Plants',
|
||||
value: metrics.plants.current,
|
||||
change: metrics.plants.current - metrics.plants.previous,
|
||||
changePercent: percentageChange(metrics.plants.current, metrics.plants.previous),
|
||||
trend: getTrendDirection(metrics.plants.current, metrics.plants.previous),
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
id: 'carbon-saved',
|
||||
title: 'Carbon Saved',
|
||||
value: metrics.carbon.current.toFixed(1),
|
||||
unit: 'kg CO2',
|
||||
change: metrics.carbon.current - metrics.carbon.previous,
|
||||
changePercent: percentageChange(metrics.carbon.current, metrics.carbon.previous),
|
||||
trend: getTrendDirection(metrics.carbon.current, metrics.carbon.previous),
|
||||
color: 'teal',
|
||||
},
|
||||
{
|
||||
id: 'food-miles',
|
||||
title: 'Food Miles',
|
||||
value: metrics.foodMiles.current.toFixed(0),
|
||||
unit: 'km',
|
||||
change: metrics.foodMiles.current - metrics.foodMiles.previous,
|
||||
changePercent: percentageChange(metrics.foodMiles.current, metrics.foodMiles.previous),
|
||||
trend: getTrendDirection(metrics.foodMiles.previous, metrics.foodMiles.current), // Inverted: lower is better
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
id: 'active-users',
|
||||
title: 'Active Users',
|
||||
value: metrics.users.current,
|
||||
change: metrics.users.current - metrics.users.previous,
|
||||
changePercent: percentageChange(metrics.users.current, metrics.users.previous),
|
||||
trend: getTrendDirection(metrics.users.current, metrics.users.previous),
|
||||
color: 'purple',
|
||||
},
|
||||
{
|
||||
id: 'sustainability',
|
||||
title: 'Sustainability Score',
|
||||
value: metrics.sustainability.current.toFixed(0),
|
||||
unit: '%',
|
||||
change: metrics.sustainability.current - metrics.sustainability.previous,
|
||||
changePercent: percentageChange(metrics.sustainability.current, metrics.sustainability.previous),
|
||||
trend: getTrendDirection(metrics.sustainability.current, metrics.sustainability.previous),
|
||||
color: 'green',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate growth metrics
|
||||
*/
|
||||
export function calculateGrowthMetrics(data: TimeSeriesDataPoint[]): {
|
||||
totalGrowth: number;
|
||||
averageDaily: number;
|
||||
peakValue: number;
|
||||
peakDate: string;
|
||||
trend: TrendDirection;
|
||||
} {
|
||||
if (data.length === 0) {
|
||||
return { totalGrowth: 0, averageDaily: 0, peakValue: 0, peakDate: '', trend: 'stable' };
|
||||
}
|
||||
|
||||
const values = data.map(d => d.value);
|
||||
const total = values.reduce((sum, v) => sum + v, 0);
|
||||
const avgDaily = total / data.length;
|
||||
const maxIndex = values.indexOf(Math.max(...values));
|
||||
|
||||
const firstHalf = mean(values.slice(0, Math.floor(values.length / 2)));
|
||||
const secondHalf = mean(values.slice(Math.floor(values.length / 2)));
|
||||
|
||||
return {
|
||||
totalGrowth: total,
|
||||
averageDaily: Math.round(avgDaily * 100) / 100,
|
||||
peakValue: values[maxIndex],
|
||||
peakDate: data[maxIndex].timestamp,
|
||||
trend: getTrendDirection(secondHalf, firstHalf),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect anomalies using z-score
|
||||
*/
|
||||
export function detectAnomalies(
|
||||
data: TimeSeriesDataPoint[],
|
||||
threshold: number = 2
|
||||
): TimeSeriesDataPoint[] {
|
||||
const values = data.map(d => d.value);
|
||||
const avg = mean(values);
|
||||
const std = standardDeviation(values);
|
||||
|
||||
if (std === 0) return [];
|
||||
|
||||
return data.filter(point => {
|
||||
const zScore = Math.abs((point.value - avg) / std);
|
||||
return zScore > threshold;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate correlation coefficient between two datasets
|
||||
*/
|
||||
export function correlationCoefficient(x: number[], y: number[]): number {
|
||||
if (x.length !== y.length || x.length === 0) return 0;
|
||||
|
||||
const n = x.length;
|
||||
const meanX = mean(x);
|
||||
const meanY = mean(y);
|
||||
|
||||
let numerator = 0;
|
||||
let denomX = 0;
|
||||
let denomY = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const dx = x[i] - meanX;
|
||||
const dy = y[i] - meanY;
|
||||
numerator += dx * dy;
|
||||
denomX += dx * dx;
|
||||
denomY += dy * dy;
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(denomX * denomY);
|
||||
return denominator === 0 ? 0 : numerator / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers for display
|
||||
*/
|
||||
export function formatNumber(value: number, decimals: number = 1): string {
|
||||
if (Math.abs(value) >= 1000000) {
|
||||
return (value / 1000000).toFixed(decimals) + 'M';
|
||||
}
|
||||
if (Math.abs(value) >= 1000) {
|
||||
return (value / 1000).toFixed(decimals) + 'K';
|
||||
}
|
||||
return value.toFixed(decimals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage for display
|
||||
*/
|
||||
export function formatPercentage(value: number, showSign: boolean = false): string {
|
||||
const formatted = value.toFixed(1);
|
||||
if (showSign && value > 0) return '+' + formatted + '%';
|
||||
return formatted + '%';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue