Compare commits
No commits in common. "baca3202627cd77eca0332aa26af43fa4b35e685" and "b0dc9fca4d421476e23c3ccb1c74493612610066" have entirely different histories.
baca320262
...
b0dc9fca4d
146 changed files with 375 additions and 30604 deletions
|
|
@ -1,259 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
/**
|
||||
* 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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
494
bun.lock
494
bun.lock
|
|
@ -5,29 +5,31 @@
|
|||
"": {
|
||||
"name": "localgreenchain",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.937.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.937.0",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@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",
|
||||
"multer": "^2.0.2",
|
||||
"next": "^12.2.3",
|
||||
"next-drupal": "^1.6.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.8.6",
|
||||
"recharts": "^3.4.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/sharp": "^0.32.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint-config-next": "^12.0.10",
|
||||
"jest": "^29.5.0",
|
||||
|
|
@ -41,6 +43,90 @@
|
|||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
|
||||
|
||||
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
|
||||
|
||||
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
|
||||
|
||||
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
|
||||
|
||||
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
|
||||
|
||||
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
|
||||
|
||||
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
|
||||
|
||||
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.937.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/credential-provider-node": "3.936.0", "@aws-sdk/middleware-bucket-endpoint": "3.936.0", "@aws-sdk/middleware-expect-continue": "3.936.0", "@aws-sdk/middleware-flexible-checksums": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-location-constraint": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-sdk-s3": "3.936.0", "@aws-sdk/middleware-ssec": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/signature-v4-multi-region": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ioeNe6HSc7PxjsUQY7foSHmgesxM5KwAeUtPhIHgKx99nrM+7xYCfW4FMvHypUzz7ZOvqlCdH7CEAZ8ParBvVg=="],
|
||||
|
||||
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.936.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-0G73S2cDqYwJVvqL08eakj79MZG2QRaB56Ul8/Ps9oQxllr7DMI1IQ/N3j3xjxgpq/U36pkoFZ8aK1n7Sbr3IQ=="],
|
||||
|
||||
"@aws-sdk/core": ["@aws-sdk/core@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/xml-builder": "3.930.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eGJ2ySUMvgtOziHhDRDLCrj473RJoL4J1vPjVM3NrKC/fF3/LoHjkut8AAnKmrW6a2uTzNKubigw8dEnpmpERw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-dKajFuaugEA5i9gCKzOaVy9uTeZcApE+7Z5wdcZ6j40523fY1a56khDAUYkCfwqa7sHci4ccmxBkAo+fW1RChA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-5FguODLXG1tWx/x8fBxH+GVrk7Hey2LbXV5h9SFzYCx/2h50URBm0+9hndg0Rd23+xzYe14F6SI9HA9c1sPnjg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/credential-provider-env": "3.936.0", "@aws-sdk/credential-provider-http": "3.936.0", "@aws-sdk/credential-provider-login": "3.936.0", "@aws-sdk/credential-provider-process": "3.936.0", "@aws-sdk/credential-provider-sso": "3.936.0", "@aws-sdk/credential-provider-web-identity": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-TbUv56ERQQujoHcLMcfL0Q6bVZfYF83gu/TjHkVkdSlHPOIKaG/mhE2XZSQzXv1cud6LlgeBbfzVAxJ+HPpffg=="],
|
||||
|
||||
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8DVrdRqPyUU66gfV7VZNToh56ZuO5D6agWrkLQE/xbLJOm2RbeRgh6buz7CqV8ipRd6m+zCl9mM4F3osQLZn8Q=="],
|
||||
|
||||
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.936.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.936.0", "@aws-sdk/credential-provider-http": "3.936.0", "@aws-sdk/credential-provider-ini": "3.936.0", "@aws-sdk/credential-provider-process": "3.936.0", "@aws-sdk/credential-provider-sso": "3.936.0", "@aws-sdk/credential-provider-web-identity": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-rk/2PCtxX9xDsQW8p5Yjoca3StqmQcSfkmD7nQ61AqAHL1YgpSQWqHE+HjfGGiHDYKG7PvE33Ku2GyA7lEIJAw=="],
|
||||
|
||||
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GpA4AcHb96KQK2PSPUyvChvrsEKiLhQ5NWjeef2IZ3Jc8JoosiedYqp6yhZR+S8cTysuvx56WyJIJc8y8OTrLA=="],
|
||||
|
||||
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.936.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.936.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/token-providers": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wHlEAJJvtnSyxTfNhN98JcU4taA1ED2JvuI2eePgawqBwS/Tzi0mhED1lvNIaWOkjfLd+nHALwszGrtJwEq4yQ=="],
|
||||
|
||||
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-v3qHAuoODkoRXsAF4RG+ZVO6q2P9yYBT4GMpMEfU9wXVNn7AIfwZgTwzSUfnjNiGva5BKleWVpRpJ9DeuLFbUg=="],
|
||||
|
||||
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-XLSVVfAorUxZh6dzF+HTOp4R1B5EQcdpGcPliWr0KUj2jukgjZEcqbBmjyMF/p9bmyQsONX80iURF1HLAlW0qg=="],
|
||||
|
||||
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Eb4ELAC23bEQLJmUMYnPWcjD3FZIsmz2svDiXEcxRkQU9r7NRID7pM7C5NPH94wOfiCk0b2Y8rVyFXW0lGQwbA=="],
|
||||
|
||||
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.936.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-l3GG6CrSQtMCM6fWY7foV3JQv0WJWT+3G6PSP3Ceb/KEE/5Lz5PrYFXTBf+bVoYL1b0bGjGajcgAXpstBmtHtQ=="],
|
||||
|
||||
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw=="],
|
||||
|
||||
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-SCMPenDtQMd9o5da9JzkHz838w3327iqXk3cbNnXWqnNRx6unyW8FL0DZ84gIY12kAyVHz5WEqlWuekc15ehfw=="],
|
||||
|
||||
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw=="],
|
||||
|
||||
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@aws/lambda-invoke-store": "^0.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA=="],
|
||||
|
||||
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-arn-parser": "3.893.0", "@smithy/core": "^3.18.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-UQs/pVq4cOygsnKON0pOdSKIWkfgY0dzq4h+fR+xHi/Ng3XzxPJhWeAE6tDsKrcyQc1X8UdSbS70XkfGYr5hng=="],
|
||||
|
||||
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-/GLC9lZdVp05ozRik5KsuODR/N7j+W+2TbfdFL3iS+7un+gnP6hC8RDOZd6WhpZp7drXQ9guKiTAxkZQwzS8DA=="],
|
||||
|
||||
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@smithy/core": "^3.18.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-YB40IPa7K3iaYX0lSnV9easDOLPLh+fJyUDF3BH8doX4i1AOSsYn86L4lVldmOaSX+DwiaqKHpvk4wPBdcIPWw=="],
|
||||
|
||||
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.936.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eyj2tz1XmDSLSZQ5xnB7cLTVKkSJnYAEoNDSUNhzWPxrBDYeJzIbatecOKceKCU8NBf8gWWZCK/CSY0mDxMO0A=="],
|
||||
|
||||
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/config-resolver": "^4.4.3", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw=="],
|
||||
|
||||
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.937.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-format-url": "3.936.0", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-AvsCt6FnnKTpkmzDA1pFzmXPyxbGBdtllOIY0mL1iNSVZ3d7SoJKZH4NaqlcgUtbYG9zVh6QfLWememj1yEAmw=="],
|
||||
|
||||
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.936.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/protocol-http": "^5.3.5", "@smithy/signature-v4": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8qS0GFUqkmwO7JZ0P8tdluBmt1UTfYUah8qJXGzNh9n1Pcb0AIeT117cCSiCUtwk+gDbJvd4hhRIhJCNr5wgjg=="],
|
||||
|
||||
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.936.0", "", { "dependencies": { "@aws-sdk/core": "3.936.0", "@aws-sdk/nested-clients": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-vvw8+VXk0I+IsoxZw0mX9TMJawUJvEsg3EF7zcCSetwhNPAU8Xmlhv7E/sN/FgSmm7b7DsqKoW6rVtQiCs1PWQ=="],
|
||||
|
||||
"@aws-sdk/types": ["@aws-sdk/types@3.936.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg=="],
|
||||
|
||||
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA=="],
|
||||
|
||||
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-endpoints": "^3.2.5", "tslib": "^2.6.2" } }, "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w=="],
|
||||
|
||||
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g=="],
|
||||
|
||||
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.893.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.936.0", "", { "dependencies": { "@aws-sdk/types": "3.936.0", "@smithy/types": "^4.9.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw=="],
|
||||
|
||||
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.936.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.936.0", "@aws-sdk/types": "3.936.0", "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-XOEc7PF9Op00pWV2AYCGDSu5iHgYjIO53Py2VUQTIvP7SRCaCsXmA33mjBvC2Ms6FhSyWNa4aK4naUGIz0hQcw=="],
|
||||
|
||||
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.930.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA=="],
|
||||
|
||||
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.1", "", {}, "sha512-sIyFcoPZkTtNu9xFeEoynMef3bPJIAbOfUh+ueYcfhVl6xm2VRtMcMclSxmZCMnHHd4hlYKJeq/aggmBEWynww=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
|
||||
|
|
@ -111,6 +197,8 @@
|
|||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
|
@ -125,6 +213,56 @@
|
|||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="],
|
||||
|
||||
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="],
|
||||
|
|
@ -203,8 +341,6 @@
|
|||
|
||||
"@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=="],
|
||||
|
|
@ -215,9 +351,107 @@
|
|||
|
||||
"@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=="],
|
||||
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
|
||||
|
||||
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
|
||||
|
||||
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.3", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw=="],
|
||||
|
||||
"@smithy/core": ["@smithy/core@3.18.5", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.6", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-6gnIz3h+PEPQGDj8MnRSjDvKBah042jEoPgjFGJ4iJLBE78L4lY/n98x14XyPF4u3lN179Ub/ZKFY5za9GeLQw=="],
|
||||
|
||||
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ=="],
|
||||
|
||||
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="],
|
||||
|
||||
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw=="],
|
||||
|
||||
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ibjQjM7wEXtECiT6my1xfiMH9IcEczMOS6xiCQXoUIYSj5b1CpBbJ3VYbdwDy8Vcg5JHN7eFpOCGk8nyZAltNQ=="],
|
||||
|
||||
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-+elOuaYx6F2H6x1/5BQP5ugv12nfJl66GhxON8+dWVUEDJ9jah/A0tayVdkLRP0AeSac0inYkDz5qBFKfVp2Gg=="],
|
||||
|
||||
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.5", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q=="],
|
||||
|
||||
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg=="],
|
||||
|
||||
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.6", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8P//tA8DVPk+3XURk2rwcKgYwFvwGwmJH/wJqQiSKwXZtf/LiZK+hbUZmPj/9KzM+OVSwe4o85KTp5x9DUZTjw=="],
|
||||
|
||||
"@smithy/hash-node": ["@smithy/hash-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA=="],
|
||||
|
||||
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6+do24VnEyvWcGdHXomlpd0m8bfZePpUKBy7m311n+JuRwug8J4dCanJdTymx//8mi0nlkflZBvJe+dEO/O12Q=="],
|
||||
|
||||
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A=="],
|
||||
|
||||
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
|
||||
|
||||
"@smithy/md5-js": ["@smithy/md5-js@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Bt6jpSTMWfjCtC0s79gZ/WZ1w90grfmopVOWqkI2ovhjpD5Q2XRXuecIPB9689L2+cCySMbaXDhBPU56FKNDNg=="],
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.5", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.3.12", "", { "dependencies": { "@smithy/core": "^3.18.5", "@smithy/middleware-serde": "^4.2.6", "@smithy/node-config-provider": "^4.3.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-middleware": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-9pAX/H+VQPzNbouhDhkW723igBMLgrI8OtX+++M7iKJgg/zY/Ig3i1e6seCcx22FWhE6Q/S61BRdi2wXBORT+A=="],
|
||||
|
||||
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.12", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/protocol-http": "^5.3.5", "@smithy/service-error-classification": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-S4kWNKFowYd0lID7/DBqWHOQxmxlsf0jBaos9chQZUWTVOjSW1Ogyh8/ib5tM+agFDJ/TCxuCTvrnlc+9cIBcQ=="],
|
||||
|
||||
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.6", "", { "dependencies": { "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ=="],
|
||||
|
||||
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ=="],
|
||||
|
||||
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.5", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg=="],
|
||||
|
||||
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/querystring-builder": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw=="],
|
||||
|
||||
"@smithy/property-provider": ["@smithy/property-provider@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg=="],
|
||||
|
||||
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ=="],
|
||||
|
||||
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg=="],
|
||||
|
||||
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ=="],
|
||||
|
||||
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0" } }, "sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ=="],
|
||||
|
||||
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.0", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA=="],
|
||||
|
||||
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.5", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.5", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w=="],
|
||||
|
||||
"@smithy/smithy-client": ["@smithy/smithy-client@4.9.8", "", { "dependencies": { "@smithy/core": "^3.18.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-stack": "^4.2.5", "@smithy/protocol-http": "^5.3.5", "@smithy/types": "^4.9.0", "@smithy/util-stream": "^4.5.6", "tslib": "^2.6.2" } }, "sha512-8xgq3LgKDEFoIrLWBho/oYKyWByw9/corz7vuh1upv7ZBm0ZMjGYBhbn6v643WoIqA9UTcx5A5htEp/YatUwMA=="],
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.9.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA=="],
|
||||
|
||||
"@smithy/url-parser": ["@smithy/url-parser@4.2.5", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ=="],
|
||||
|
||||
"@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
|
||||
|
||||
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
|
||||
|
||||
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
|
||||
|
||||
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
|
||||
|
||||
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
|
||||
|
||||
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-yHv+r6wSQXEXTPVCIQTNmXVWs7ekBTpMVErjqZoWkYN75HIFN5y9+/+sYOejfAuvxWGvgzgxbTHa/oz61YTbKw=="],
|
||||
|
||||
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.14", "", { "dependencies": { "@smithy/config-resolver": "^4.4.3", "@smithy/credential-provider-imds": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-ljZN3iRvaJUgulfvobIuG97q1iUuCMrvXAlkZ4msY+ZuVHQHDIqn7FKZCEj+bx8omz6kF5yQXms/xhzjIO5XiA=="],
|
||||
|
||||
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A=="],
|
||||
|
||||
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
|
||||
|
||||
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.5", "", { "dependencies": { "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA=="],
|
||||
|
||||
"@smithy/util-retry": ["@smithy/util-retry@4.2.5", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg=="],
|
||||
|
||||
"@smithy/util-stream": ["@smithy/util-stream@4.5.6", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.6", "@smithy/node-http-handler": "^4.4.5", "@smithy/types": "^4.9.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ=="],
|
||||
|
||||
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
|
||||
|
||||
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
|
||||
|
||||
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.5", "", { "dependencies": { "@smithy/abort-controller": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-Dbun99A3InifQdIrsXZ+QLcC0PGBPAdrl4cj1mTgJvyc9N2zf7QSxg8TBkzsCmGJdE3TLbO9ycwpY0EkWahQ/g=="],
|
||||
|
||||
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.4.11", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw=="],
|
||||
|
||||
|
|
@ -237,72 +471,18 @@
|
|||
|
||||
"@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/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
|
||||
"@types/express": ["@types/express@5.0.5", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^1" } }, "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ=="],
|
||||
|
||||
"@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/express-serve-static-core": ["@types/express-serve-static-core@5.1.0", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA=="],
|
||||
|
||||
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
|
||||
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
|
||||
|
|
@ -313,17 +493,29 @@
|
|||
|
||||
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
|
||||
|
||||
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
|
||||
|
||||
"@types/multer": ["@types/multer@2.0.0", "", { "dependencies": { "@types/express": "*" } }, "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw=="],
|
||||
|
||||
"@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/react": ["@types/react@17.0.90", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "^0.16", "csstype": "^3.2.2" } }, "sha512-P9beVR/x06U9rCJzSxtENnOr4BrbJ6VrsrDTc+73TtHv9XHhryXKbjGRB+6oooB2r0G/pQkD/S4dHo/7jUfwFw=="],
|
||||
|
||||
"@types/scheduler": ["@types/scheduler@0.16.8", "", {}, "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
"@types/sharp": ["@types/sharp@0.32.0", "", { "dependencies": { "sharp": "*" } }, "sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw=="],
|
||||
|
||||
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
|
||||
|
||||
"@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="],
|
||||
|
||||
|
|
@ -359,6 +551,8 @@
|
|||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
|
@ -411,6 +605,8 @@
|
|||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"bowser": ["bowser@2.12.1", "", {}, "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
|
@ -423,6 +619,8 @@
|
|||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
|
@ -453,8 +651,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -467,6 +663,8 @@
|
|||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
|
||||
|
|
@ -477,68 +675,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -547,12 +683,8 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -563,7 +695,7 @@
|
|||
|
||||
"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-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
|
||||
|
||||
|
|
@ -615,8 +747,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -655,8 +785,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -671,6 +799,8 @@
|
|||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="],
|
||||
|
|
@ -759,12 +889,8 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -779,8 +905,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -969,12 +1093,18 @@
|
|||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"mini-svg-data-uri": ["mini-svg-data-uri@1.4.4", "", { "bin": { "mini-svg-data-uri": "cli.js" } }, "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="],
|
||||
|
|
@ -983,8 +1113,12 @@
|
|||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"multer": ["multer@2.0.2", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "concat-stream": "^2.0.0", "mkdirp": "^0.5.6", "object-assign": "^4.1.1", "type-is": "^1.6.18", "xtend": "^4.0.2" } }, "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
|
@ -1109,26 +1243,18 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -1141,20 +1267,16 @@
|
|||
|
||||
"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-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"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=="],
|
||||
|
|
@ -1165,6 +1287,8 @@
|
|||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
|
@ -1201,6 +1325,8 @@
|
|||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
|
@ -1217,6 +1343,8 @@
|
|||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||
|
|
@ -1225,6 +1353,8 @@
|
|||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"strnum": ["strnum@2.1.1", "", {}, "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.1", "", { "dependencies": { "style-to-object": "0.3.0" } }, "sha512-RJ18Z9t2B02sYhZtfWKQq5uplVctgvjTfLWT7+Eb1zjUjIrWzX5SdlkwLGQozrqarTmEzJJ/YmdNJCUNI47elg=="],
|
||||
|
||||
"style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="],
|
||||
|
|
@ -1247,8 +1377,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -1271,6 +1399,8 @@
|
|||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||
|
|
@ -1279,6 +1409,8 @@
|
|||
|
||||
"typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="],
|
||||
|
||||
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
|
||||
|
||||
"typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="],
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
|
@ -1295,8 +1427,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -1319,6 +1449,8 @@
|
|||
|
||||
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
|
@ -1329,6 +1461,12 @@
|
|||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
|
@ -1339,6 +1477,8 @@
|
|||
|
||||
"@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
|
||||
|
|
@ -1349,8 +1489,6 @@
|
|||
|
||||
"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=="],
|
||||
|
|
@ -1395,6 +1533,8 @@
|
|||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
|
@ -1411,12 +1551,24 @@
|
|||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
|
|
|||
|
|
@ -28,11 +28,10 @@ export default function EnvironmentalForm({
|
|||
section: K,
|
||||
updates: Partial<GrowingEnvironment[K]>
|
||||
) => {
|
||||
const currentSection = value[section] || {};
|
||||
onChange({
|
||||
...value,
|
||||
[section]: {
|
||||
...(currentSection as object || {}),
|
||||
...value[section],
|
||||
...updates,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,229 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/**
|
||||
* 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,19 +1,3 @@
|
|||
/**
|
||||
* 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';
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
// 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';
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export { BottomNav } from './BottomNav';
|
||||
export { MobileHeader } from './MobileHeader';
|
||||
export { InstallPrompt } from './InstallPrompt';
|
||||
export { SwipeableCard } from './SwipeableCard';
|
||||
export { PullToRefresh } from './PullToRefresh';
|
||||
export { QRScanner } from './QRScanner';
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,229 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* Notification Components Index
|
||||
*/
|
||||
|
||||
export { NotificationBell } from './NotificationBell';
|
||||
export { NotificationList } from './NotificationList';
|
||||
export { NotificationItem } from './NotificationItem';
|
||||
export { PreferencesForm } from './PreferencesForm';
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,255 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
/**
|
||||
* 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';
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
#!/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);
|
||||
});
|
||||
|
|
@ -201,7 +201,7 @@ export class AgentOrchestrator {
|
|||
}
|
||||
|
||||
// Stop all agents
|
||||
for (const agent of Array.from(this.agents.values())) {
|
||||
for (const agent of 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 Array.from(this.agents.entries())) {
|
||||
for (const [agentId, agent] of this.agents) {
|
||||
const health = this.getAgentHealth(agentId);
|
||||
|
||||
if (!health.isHealthy) {
|
||||
|
|
@ -296,7 +296,7 @@ export class AgentOrchestrator {
|
|||
private aggregateAlerts(): void {
|
||||
this.aggregatedAlerts = [];
|
||||
|
||||
for (const agent of Array.from(this.agents.values())) {
|
||||
for (const agent of 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.chain;
|
||||
const chain = blockchain.getChain();
|
||||
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?.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);
|
||||
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);
|
||||
}
|
||||
|
||||
const profile: EnvironmentProfile = existing || {
|
||||
|
|
@ -357,16 +357,15 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
|
||||
// Lighting analysis
|
||||
if (env.lighting) {
|
||||
const lightHours = env.lighting.naturalLight?.hoursPerDay || env.lighting.artificialLight?.hoursPerDay;
|
||||
const lightDiff = lightHours
|
||||
? Math.abs(lightHours - profile.optimalConditions.lightHours.optimal)
|
||||
const lightDiff = env.lighting.hoursPerDay
|
||||
? Math.abs(env.lighting.hoursPerDay - profile.optimalConditions.lightHours.optimal)
|
||||
: 2;
|
||||
lightingScore = Math.max(0, 100 - lightDiff * 15);
|
||||
|
||||
if (lightDiff > 2) {
|
||||
improvements.push({
|
||||
category: 'lighting',
|
||||
currentState: `${lightHours || 'unknown'} hours/day`,
|
||||
currentState: `${env.lighting.hoursPerDay || 'unknown'} hours/day`,
|
||||
recommendedState: `${profile.optimalConditions.lightHours.optimal} hours/day`,
|
||||
priority: lightDiff > 4 ? 'high' : 'medium',
|
||||
expectedImpact: 'Better photosynthesis and growth',
|
||||
|
|
@ -377,11 +376,11 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
|
||||
// Climate analysis
|
||||
if (env.climate) {
|
||||
const tempDiff = env.climate.temperatureDay
|
||||
? Math.abs(env.climate.temperatureDay - profile.optimalConditions.temperature.optimal)
|
||||
const tempDiff = env.climate.avgTemperature
|
||||
? Math.abs(env.climate.avgTemperature - profile.optimalConditions.temperature.optimal)
|
||||
: 5;
|
||||
const humDiff = env.climate.humidityAverage
|
||||
? Math.abs(env.climate.humidityAverage - profile.optimalConditions.humidity.optimal)
|
||||
const humDiff = env.climate.avgHumidity
|
||||
? Math.abs(env.climate.avgHumidity - profile.optimalConditions.humidity.optimal)
|
||||
: 10;
|
||||
|
||||
climateScore = Math.max(0, 100 - tempDiff * 5 - humDiff * 1);
|
||||
|
|
@ -389,7 +388,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
if (tempDiff > 3) {
|
||||
improvements.push({
|
||||
category: 'climate',
|
||||
currentState: `${env.climate.temperatureDay?.toFixed(1) || 'unknown'}°C`,
|
||||
currentState: `${env.climate.avgTemperature?.toFixed(1) || 'unknown'}°C`,
|
||||
recommendedState: `${profile.optimalConditions.temperature.optimal}°C`,
|
||||
priority: tempDiff > 6 ? 'high' : 'medium',
|
||||
expectedImpact: 'Reduced stress and improved growth',
|
||||
|
|
@ -409,8 +408,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
// Nutrients analysis
|
||||
if (env.nutrients) {
|
||||
nutrientsScore = 75; // Base score if nutrient data exists
|
||||
// Bonus for complete NPK profile
|
||||
if (env.nutrients.nitrogen && env.nutrients.phosphorus && env.nutrients.potassium) {
|
||||
if (env.nutrients.fertilizer?.schedule === 'regular') {
|
||||
nutrientsScore = 90;
|
||||
}
|
||||
}
|
||||
|
|
@ -464,7 +462,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
|
||||
// Find common soil types
|
||||
const soilTypes = plantsWithEnv
|
||||
.map(p => p.plant.environment?.soil?.type)
|
||||
.map(p => p.plant.environment?.soil?.soilType)
|
||||
.filter(Boolean);
|
||||
|
||||
const commonSoilType = this.findMostCommon(soilTypes as string[]);
|
||||
|
|
@ -473,7 +471,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
patterns.push({
|
||||
patternId: `pattern-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`,
|
||||
species,
|
||||
conditions: { soil: { type: commonSoilType } } as any,
|
||||
conditions: { soil: { soilType: commonSoilType } } as any,
|
||||
successMetric: 'health',
|
||||
successValue: 85,
|
||||
sampleSize: plantsWithEnv.length,
|
||||
|
|
@ -529,7 +527,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
if (cached) return cached;
|
||||
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.chain;
|
||||
const chain = blockchain.getChain();
|
||||
|
||||
const block1 = chain.find(b => b.plant.id === plant1Id);
|
||||
const block2 = chain.find(b => b.plant.id === plant2Id);
|
||||
|
|
@ -547,14 +545,14 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
// Compare soil
|
||||
if (env1?.soil && env2?.soil) {
|
||||
totalFactors++;
|
||||
if (env1.soil.type === env2.soil.type) {
|
||||
if (env1.soil.soilType === env2.soil.soilType) {
|
||||
matchingFactors.push('Soil type');
|
||||
matchScore++;
|
||||
} else {
|
||||
differingFactors.push({
|
||||
factor: 'Soil type',
|
||||
plant1Value: env1.soil.type,
|
||||
plant2Value: env2.soil.type
|
||||
plant1Value: env1.soil.soilType,
|
||||
plant2Value: env2.soil.soilType
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -590,7 +588,7 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
if (env1?.climate && env2?.climate) {
|
||||
totalFactors++;
|
||||
const tempDiff = Math.abs(
|
||||
(env1.climate.temperatureDay || 0) - (env2.climate.temperatureDay || 0)
|
||||
(env1.climate.avgTemperature || 0) - (env2.climate.avgTemperature || 0)
|
||||
);
|
||||
if (tempDiff < 3) {
|
||||
matchingFactors.push('Temperature');
|
||||
|
|
@ -598,8 +596,8 @@ export class EnvironmentAnalysisAgent extends BaseAgent {
|
|||
} else {
|
||||
differingFactors.push({
|
||||
factor: 'Temperature',
|
||||
plant1Value: env1.climate.temperatureDay,
|
||||
plant2Value: env2.climate.temperatureDay
|
||||
plant1Value: env1.climate.avgTemperature,
|
||||
plant2Value: env2.climate.avgTemperature
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
|
|||
*/
|
||||
private updateGrowerProfiles(): void {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.chain.slice(1);
|
||||
const chain = blockchain.getChain().slice(1);
|
||||
|
||||
const ownerPlants = new Map<string, typeof chain>();
|
||||
|
||||
|
|
@ -219,11 +219,7 @@ export class GrowerAdvisoryAgent extends BaseAgent {
|
|||
if (['growing', 'mature', 'flowering', 'fruiting'].includes(plant.plant.status)) {
|
||||
existing.healthy++;
|
||||
}
|
||||
// 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;
|
||||
existing.yield += plant.plant.growthMetrics?.estimatedYieldKg || 2;
|
||||
historyMap.set(crop, existing);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export class NetworkDiscoveryAgent extends BaseAgent {
|
|||
*/
|
||||
async runOnce(): Promise<AgentTask | null> {
|
||||
const blockchain = getBlockchain();
|
||||
const chain = blockchain.chain;
|
||||
const chain = blockchain.getChain();
|
||||
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.chain;
|
||||
const chain = blockchain.getChain();
|
||||
|
||||
// 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.registeredAt,
|
||||
oldestAncestorDate: oldestAncestor?.timestamp || plant.dateAcquired,
|
||||
healthScore: this.calculateHealthScore(plant, chain)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ export class SustainabilityAgent extends BaseAgent {
|
|||
*/
|
||||
private calculateWaterMetrics(): WaterMetrics {
|
||||
const blockchain = getBlockchain();
|
||||
const plantCount = blockchain.chain.length - 1;
|
||||
const plantCount = blockchain.getChain().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.chain.slice(1);
|
||||
const plants = blockchain.getChain().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.chain.slice(1);
|
||||
const plants = blockchain.getChain().slice(1);
|
||||
const uniqueSpecies = new Set(plants.map(p => p.plant.commonName)).size;
|
||||
const biodiversity = Math.min(100, 30 + uniqueSpecies * 5);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,406 +0,0 @@
|
|||
/**
|
||||
* 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)}`;
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* 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';
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
/**
|
||||
* 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 + '%';
|
||||
}
|
||||
|
|
@ -1,411 +0,0 @@
|
|||
/**
|
||||
* Trend Analysis for Analytics
|
||||
* Provides trend detection, forecasting, and pattern analysis
|
||||
*/
|
||||
|
||||
import { TimeSeriesDataPoint, TrendDirection, TrendData, TimeRange } from './types';
|
||||
import { mean, standardDeviation, percentageChange, movingAverage } from './metrics';
|
||||
import { format, parseISO, differenceInDays } from 'date-fns';
|
||||
|
||||
/**
|
||||
* Analyze trend from time series data
|
||||
*/
|
||||
export function analyzeTrend(data: TimeSeriesDataPoint[]): TrendData {
|
||||
if (data.length < 2) {
|
||||
return {
|
||||
metric: 'Unknown',
|
||||
currentValue: data[0]?.value || 0,
|
||||
previousValue: 0,
|
||||
change: 0,
|
||||
changePercent: 0,
|
||||
direction: 'stable',
|
||||
period: 'N/A',
|
||||
};
|
||||
}
|
||||
|
||||
const midpoint = Math.floor(data.length / 2);
|
||||
const firstHalf = data.slice(0, midpoint);
|
||||
const secondHalf = data.slice(midpoint);
|
||||
|
||||
const firstAvg = mean(firstHalf.map(d => d.value));
|
||||
const secondAvg = mean(secondHalf.map(d => d.value));
|
||||
const change = secondAvg - firstAvg;
|
||||
const changePercent = percentageChange(secondAvg, firstAvg);
|
||||
|
||||
let direction: TrendDirection = 'stable';
|
||||
if (changePercent > 5) direction = 'up';
|
||||
else if (changePercent < -5) direction = 'down';
|
||||
|
||||
return {
|
||||
metric: '',
|
||||
currentValue: secondAvg,
|
||||
previousValue: firstAvg,
|
||||
change,
|
||||
changePercent: Math.round(changePercent * 10) / 10,
|
||||
direction,
|
||||
period: `${data[0].timestamp} - ${data[data.length - 1].timestamp}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate linear regression for forecasting
|
||||
*/
|
||||
export function linearRegression(data: TimeSeriesDataPoint[]): {
|
||||
slope: number;
|
||||
intercept: number;
|
||||
rSquared: number;
|
||||
} {
|
||||
const n = data.length;
|
||||
if (n < 2) return { slope: 0, intercept: 0, rSquared: 0 };
|
||||
|
||||
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
|
||||
|
||||
data.forEach((point, i) => {
|
||||
const x = i;
|
||||
const y = point.value;
|
||||
sumX += x;
|
||||
sumY += y;
|
||||
sumXY += x * y;
|
||||
sumX2 += x * x;
|
||||
sumY2 += y * y;
|
||||
});
|
||||
|
||||
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
||||
const intercept = (sumY - slope * sumX) / n;
|
||||
|
||||
// Calculate R-squared
|
||||
const yMean = sumY / n;
|
||||
let ssRes = 0, ssTot = 0;
|
||||
data.forEach((point, i) => {
|
||||
const predicted = slope * i + intercept;
|
||||
ssRes += Math.pow(point.value - predicted, 2);
|
||||
ssTot += Math.pow(point.value - yMean, 2);
|
||||
});
|
||||
const rSquared = ssTot === 0 ? 1 : 1 - ssRes / ssTot;
|
||||
|
||||
return {
|
||||
slope: Math.round(slope * 1000) / 1000,
|
||||
intercept: Math.round(intercept * 1000) / 1000,
|
||||
rSquared: Math.round(rSquared * 1000) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forecast future values using linear regression
|
||||
*/
|
||||
export function forecast(
|
||||
data: TimeSeriesDataPoint[],
|
||||
periodsAhead: number
|
||||
): TimeSeriesDataPoint[] {
|
||||
const regression = linearRegression(data);
|
||||
const predictions: TimeSeriesDataPoint[] = [];
|
||||
const lastIndex = data.length - 1;
|
||||
|
||||
for (let i = 1; i <= periodsAhead; i++) {
|
||||
const predictedValue = regression.slope * (lastIndex + i) + regression.intercept;
|
||||
predictions.push({
|
||||
timestamp: `+${i}`,
|
||||
value: Math.max(0, Math.round(predictedValue * 100) / 100),
|
||||
label: `Forecast ${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
return predictions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect seasonality in data
|
||||
*/
|
||||
export function detectSeasonality(data: TimeSeriesDataPoint[]): {
|
||||
hasSeasonality: boolean;
|
||||
period: number;
|
||||
strength: number;
|
||||
} {
|
||||
if (data.length < 14) {
|
||||
return { hasSeasonality: false, period: 0, strength: 0 };
|
||||
}
|
||||
|
||||
// Try common periods: 7 days (weekly), 30 days (monthly)
|
||||
const periods = [7, 14, 30];
|
||||
let bestPeriod = 0;
|
||||
let bestCorrelation = 0;
|
||||
|
||||
for (const period of periods) {
|
||||
if (data.length < period * 2) continue;
|
||||
|
||||
let correlation = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = period; i < data.length; i++) {
|
||||
correlation += Math.abs(data[i].value - data[i - period].value);
|
||||
count++;
|
||||
}
|
||||
|
||||
const avgCorr = count > 0 ? 1 - correlation / (count * mean(data.map(d => d.value))) : 0;
|
||||
if (avgCorr > bestCorrelation) {
|
||||
bestCorrelation = avgCorr;
|
||||
bestPeriod = period;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasSeasonality: bestCorrelation > 0.5,
|
||||
period: bestPeriod,
|
||||
strength: Math.round(bestCorrelation * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify peaks and valleys in data
|
||||
*/
|
||||
export function findPeaksAndValleys(
|
||||
data: TimeSeriesDataPoint[],
|
||||
sensitivity: number = 1.5
|
||||
): {
|
||||
peaks: TimeSeriesDataPoint[];
|
||||
valleys: TimeSeriesDataPoint[];
|
||||
} {
|
||||
const peaks: TimeSeriesDataPoint[] = [];
|
||||
const valleys: TimeSeriesDataPoint[] = [];
|
||||
|
||||
if (data.length < 3) return { peaks, valleys };
|
||||
|
||||
const avg = mean(data.map(d => d.value));
|
||||
const std = standardDeviation(data.map(d => d.value));
|
||||
const threshold = std * sensitivity;
|
||||
|
||||
for (let i = 1; i < data.length - 1; i++) {
|
||||
const prev = data[i - 1].value;
|
||||
const curr = data[i].value;
|
||||
const next = data[i + 1].value;
|
||||
|
||||
// Peak detection
|
||||
if (curr > prev && curr > next && curr > avg + threshold) {
|
||||
peaks.push(data[i]);
|
||||
}
|
||||
|
||||
// Valley detection
|
||||
if (curr < prev && curr < next && curr < avg - threshold) {
|
||||
valleys.push(data[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return { peaks, valleys };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trend momentum
|
||||
*/
|
||||
export function calculateMomentum(data: TimeSeriesDataPoint[], lookback: number = 5): number {
|
||||
if (data.length < lookback) return 0;
|
||||
|
||||
const recent = data.slice(-lookback);
|
||||
const older = data.slice(-lookback * 2, -lookback);
|
||||
|
||||
if (older.length === 0) return 0;
|
||||
|
||||
const recentAvg = mean(recent.map(d => d.value));
|
||||
const olderAvg = mean(older.map(d => d.value));
|
||||
|
||||
return Math.round(((recentAvg - olderAvg) / olderAvg) * 100 * 10) / 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smooth data using exponential smoothing
|
||||
*/
|
||||
export function exponentialSmoothing(
|
||||
data: TimeSeriesDataPoint[],
|
||||
alpha: number = 0.3
|
||||
): TimeSeriesDataPoint[] {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const smoothed: TimeSeriesDataPoint[] = [data[0]];
|
||||
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const smoothedValue = alpha * data[i].value + (1 - alpha) * smoothed[i - 1].value;
|
||||
smoothed.push({
|
||||
...data[i],
|
||||
value: Math.round(smoothedValue * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trend summary text
|
||||
*/
|
||||
export function generateTrendSummary(data: TimeSeriesDataPoint[], metricName: string): string {
|
||||
if (data.length < 2) return `Insufficient data for ${metricName} analysis.`;
|
||||
|
||||
const trend = analyzeTrend(data);
|
||||
const regression = linearRegression(data);
|
||||
const { peaks, valleys } = findPeaksAndValleys(data);
|
||||
const momentum = calculateMomentum(data);
|
||||
|
||||
let summary = '';
|
||||
|
||||
// Overall direction
|
||||
if (trend.direction === 'up') {
|
||||
summary += `${metricName} is trending upward with a ${Math.abs(trend.changePercent).toFixed(1)}% increase. `;
|
||||
} else if (trend.direction === 'down') {
|
||||
summary += `${metricName} is trending downward with a ${Math.abs(trend.changePercent).toFixed(1)}% decrease. `;
|
||||
} else {
|
||||
summary += `${metricName} remains relatively stable. `;
|
||||
}
|
||||
|
||||
// Trend strength
|
||||
if (regression.rSquared > 0.8) {
|
||||
summary += 'The trend is strong and consistent. ';
|
||||
} else if (regression.rSquared > 0.5) {
|
||||
summary += 'The trend is moderate with some variability. ';
|
||||
} else {
|
||||
summary += 'Data shows high variability. ';
|
||||
}
|
||||
|
||||
// Momentum
|
||||
if (momentum > 10) {
|
||||
summary += 'Recent acceleration detected. ';
|
||||
} else if (momentum < -10) {
|
||||
summary += 'Recent deceleration detected. ';
|
||||
}
|
||||
|
||||
// Notable events
|
||||
if (peaks.length > 0) {
|
||||
summary += `${peaks.length} notable peak(s) observed. `;
|
||||
}
|
||||
if (valleys.length > 0) {
|
||||
summary += `${valleys.length} notable dip(s) observed. `;
|
||||
}
|
||||
|
||||
return summary.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two time series for similarity
|
||||
*/
|
||||
export function compareTimeSeries(
|
||||
series1: TimeSeriesDataPoint[],
|
||||
series2: TimeSeriesDataPoint[]
|
||||
): {
|
||||
correlation: number;
|
||||
leadLag: number;
|
||||
divergence: number;
|
||||
} {
|
||||
// Ensure same length
|
||||
const minLength = Math.min(series1.length, series2.length);
|
||||
const s1 = series1.slice(0, minLength).map(d => d.value);
|
||||
const s2 = series2.slice(0, minLength).map(d => d.value);
|
||||
|
||||
// Calculate correlation
|
||||
const mean1 = mean(s1);
|
||||
const mean2 = mean(s2);
|
||||
let numerator = 0, denom1 = 0, denom2 = 0;
|
||||
|
||||
for (let i = 0; i < minLength; i++) {
|
||||
const d1 = s1[i] - mean1;
|
||||
const d2 = s2[i] - mean2;
|
||||
numerator += d1 * d2;
|
||||
denom1 += d1 * d1;
|
||||
denom2 += d2 * d2;
|
||||
}
|
||||
|
||||
const correlation = denom1 * denom2 === 0 ? 0 : numerator / Math.sqrt(denom1 * denom2);
|
||||
|
||||
// Calculate divergence (normalized difference)
|
||||
const divergence = mean(s1.map((v, i) => Math.abs(v - s2[i]))) / ((mean1 + mean2) / 2);
|
||||
|
||||
return {
|
||||
correlation: Math.round(correlation * 1000) / 1000,
|
||||
leadLag: 0, // Simplified - full cross-correlation would require more computation
|
||||
divergence: Math.round(divergence * 1000) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trend confidence level
|
||||
*/
|
||||
export function getTrendConfidence(data: TimeSeriesDataPoint[]): {
|
||||
level: 'high' | 'medium' | 'low';
|
||||
score: number;
|
||||
factors: string[];
|
||||
} {
|
||||
const factors: string[] = [];
|
||||
let score = 50;
|
||||
|
||||
// Data quantity factor
|
||||
if (data.length >= 30) {
|
||||
score += 15;
|
||||
factors.push('Sufficient data points');
|
||||
} else if (data.length >= 14) {
|
||||
score += 10;
|
||||
factors.push('Moderate data points');
|
||||
} else {
|
||||
score -= 10;
|
||||
factors.push('Limited data points');
|
||||
}
|
||||
|
||||
// Consistency factor
|
||||
const std = standardDeviation(data.map(d => d.value));
|
||||
const avg = mean(data.map(d => d.value));
|
||||
const cv = avg !== 0 ? std / avg : 0;
|
||||
|
||||
if (cv < 0.2) {
|
||||
score += 15;
|
||||
factors.push('Low variability');
|
||||
} else if (cv < 0.5) {
|
||||
score += 5;
|
||||
factors.push('Moderate variability');
|
||||
} else {
|
||||
score -= 10;
|
||||
factors.push('High variability');
|
||||
}
|
||||
|
||||
// Trend strength
|
||||
const regression = linearRegression(data);
|
||||
if (regression.rSquared > 0.7) {
|
||||
score += 20;
|
||||
factors.push('Strong trend fit');
|
||||
} else if (regression.rSquared > 0.4) {
|
||||
score += 10;
|
||||
factors.push('Moderate trend fit');
|
||||
} else {
|
||||
score -= 5;
|
||||
factors.push('Weak trend fit');
|
||||
}
|
||||
|
||||
const level = score >= 70 ? 'high' : score >= 50 ? 'medium' : 'low';
|
||||
|
||||
return {
|
||||
level,
|
||||
score: Math.min(100, Math.max(0, score)),
|
||||
factors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate year-over-year comparison
|
||||
*/
|
||||
export function yearOverYearComparison(
|
||||
currentPeriod: TimeSeriesDataPoint[],
|
||||
previousPeriod: TimeSeriesDataPoint[]
|
||||
): {
|
||||
currentTotal: number;
|
||||
previousTotal: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
trend: TrendDirection;
|
||||
} {
|
||||
const currentTotal = currentPeriod.reduce((sum, d) => sum + d.value, 0);
|
||||
const previousTotal = previousPeriod.reduce((sum, d) => sum + d.value, 0);
|
||||
const change = currentTotal - previousTotal;
|
||||
const changePercent = percentageChange(currentTotal, previousTotal);
|
||||
|
||||
return {
|
||||
currentTotal: Math.round(currentTotal * 100) / 100,
|
||||
previousTotal: Math.round(previousTotal * 100) / 100,
|
||||
change: Math.round(change * 100) / 100,
|
||||
changePercent: Math.round(changePercent * 10) / 10,
|
||||
trend: changePercent > 5 ? 'up' : changePercent < -5 ? 'down' : 'stable',
|
||||
};
|
||||
}
|
||||
|
|
@ -1,306 +0,0 @@
|
|||
/**
|
||||
* Analytics Types for LocalGreenChain
|
||||
* Defines all types for the Advanced Analytics Dashboard
|
||||
*/
|
||||
|
||||
// Time range options for analytics queries
|
||||
export type TimeRange = '7d' | '30d' | '90d' | '365d' | 'all';
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
// Generic data point for time series
|
||||
export interface TimeSeriesDataPoint {
|
||||
timestamp: string;
|
||||
value: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Analytics overview metrics
|
||||
export interface AnalyticsOverview {
|
||||
totalPlants: number;
|
||||
plantsRegisteredToday: number;
|
||||
plantsRegisteredThisWeek: number;
|
||||
plantsRegisteredThisMonth: number;
|
||||
totalTransportEvents: number;
|
||||
totalCarbonKg: number;
|
||||
totalFoodMiles: number;
|
||||
activeUsers: number;
|
||||
growthRate: number;
|
||||
trendsData: TrendData[];
|
||||
}
|
||||
|
||||
// Trend direction indicator
|
||||
export type TrendDirection = 'up' | 'down' | 'stable';
|
||||
|
||||
export interface TrendData {
|
||||
metric: string;
|
||||
currentValue: number;
|
||||
previousValue: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
direction: TrendDirection;
|
||||
period: string;
|
||||
}
|
||||
|
||||
// Plant analytics
|
||||
export interface PlantAnalytics {
|
||||
totalPlants: number;
|
||||
plantsBySpecies: SpeciesDistribution[];
|
||||
plantsByGeneration: GenerationDistribution[];
|
||||
registrationsTrend: TimeSeriesDataPoint[];
|
||||
averageLineageDepth: number;
|
||||
topGrowers: GrowerStats[];
|
||||
recentRegistrations: PlantRegistration[];
|
||||
}
|
||||
|
||||
export interface SpeciesDistribution {
|
||||
species: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
trend: TrendDirection;
|
||||
}
|
||||
|
||||
export interface GenerationDistribution {
|
||||
generation: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface GrowerStats {
|
||||
userId: string;
|
||||
name: string;
|
||||
totalPlants: number;
|
||||
totalSpecies: number;
|
||||
averageGeneration: number;
|
||||
}
|
||||
|
||||
export interface PlantRegistration {
|
||||
id: string;
|
||||
name: string;
|
||||
species: string;
|
||||
registeredAt: string;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
// Transport analytics
|
||||
export interface TransportAnalytics {
|
||||
totalEvents: number;
|
||||
totalDistanceKm: number;
|
||||
totalCarbonKg: number;
|
||||
carbonSavedKg: number;
|
||||
eventsByType: TransportEventDistribution[];
|
||||
eventsByMethod: TransportMethodDistribution[];
|
||||
dailyStats: DailyTransportStats[];
|
||||
averageDistancePerEvent: number;
|
||||
mostEfficientRoutes: EfficientRoute[];
|
||||
carbonTrend: TimeSeriesDataPoint[];
|
||||
}
|
||||
|
||||
export interface TransportEventDistribution {
|
||||
eventType: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
carbonKg: number;
|
||||
}
|
||||
|
||||
export interface TransportMethodDistribution {
|
||||
method: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
distanceKm: number;
|
||||
carbonKg: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
export interface DailyTransportStats {
|
||||
date: string;
|
||||
eventCount: number;
|
||||
distanceKm: number;
|
||||
carbonKg: number;
|
||||
}
|
||||
|
||||
export interface EfficientRoute {
|
||||
from: string;
|
||||
to: string;
|
||||
method: string;
|
||||
distanceKm: number;
|
||||
carbonKg: number;
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
// Vertical farm analytics
|
||||
export interface FarmAnalytics {
|
||||
totalFarms: number;
|
||||
totalZones: number;
|
||||
activeBatches: number;
|
||||
completedBatches: number;
|
||||
averageYieldKg: number;
|
||||
resourceUsage: ResourceUsageStats;
|
||||
performanceByZone: ZonePerformance[];
|
||||
batchCompletionTrend: TimeSeriesDataPoint[];
|
||||
yieldPredictions: YieldPrediction[];
|
||||
topPerformingCrops: CropPerformance[];
|
||||
}
|
||||
|
||||
export interface ResourceUsageStats {
|
||||
waterLiters: number;
|
||||
energyKwh: number;
|
||||
nutrientsKg: number;
|
||||
waterEfficiency: number;
|
||||
energyEfficiency: number;
|
||||
}
|
||||
|
||||
export interface ZonePerformance {
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
currentCrop: string;
|
||||
healthScore: number;
|
||||
yieldKg: number;
|
||||
efficiency: number;
|
||||
}
|
||||
|
||||
export interface YieldPrediction {
|
||||
cropType: string;
|
||||
predictedYieldKg: number;
|
||||
confidence: number;
|
||||
harvestDate: string;
|
||||
}
|
||||
|
||||
export interface CropPerformance {
|
||||
cropType: string;
|
||||
averageYieldKg: number;
|
||||
growthDays: number;
|
||||
successRate: number;
|
||||
batches: number;
|
||||
}
|
||||
|
||||
// Sustainability analytics
|
||||
export interface SustainabilityAnalytics {
|
||||
overallScore: number;
|
||||
carbonFootprint: CarbonMetrics;
|
||||
foodMiles: FoodMilesMetrics;
|
||||
waterUsage: WaterMetrics;
|
||||
localProduction: LocalProductionMetrics;
|
||||
goals: SustainabilityGoal[];
|
||||
trends: SustainabilityTrend[];
|
||||
}
|
||||
|
||||
export interface CarbonMetrics {
|
||||
totalEmittedKg: number;
|
||||
totalSavedKg: number;
|
||||
netImpactKg: number;
|
||||
reductionPercentage: number;
|
||||
equivalentTrees: number;
|
||||
monthlyTrend: TimeSeriesDataPoint[];
|
||||
}
|
||||
|
||||
export interface FoodMilesMetrics {
|
||||
totalMiles: number;
|
||||
averageMilesPerPlant: number;
|
||||
savedMiles: number;
|
||||
localPercentage: number;
|
||||
monthlyTrend: TimeSeriesDataPoint[];
|
||||
}
|
||||
|
||||
export interface WaterMetrics {
|
||||
totalUsedLiters: number;
|
||||
savedLiters: number;
|
||||
efficiencyScore: number;
|
||||
perKgProduce: number;
|
||||
}
|
||||
|
||||
export interface LocalProductionMetrics {
|
||||
localCount: number;
|
||||
totalCount: number;
|
||||
percentage: number;
|
||||
trend: TrendDirection;
|
||||
}
|
||||
|
||||
export interface SustainabilityGoal {
|
||||
id: string;
|
||||
name: string;
|
||||
target: number;
|
||||
current: number;
|
||||
unit: string;
|
||||
progress: number;
|
||||
deadline: string;
|
||||
status: 'on_track' | 'at_risk' | 'behind';
|
||||
}
|
||||
|
||||
export interface SustainabilityTrend {
|
||||
metric: string;
|
||||
values: TimeSeriesDataPoint[];
|
||||
}
|
||||
|
||||
// Export options
|
||||
export interface ExportOptions {
|
||||
format: 'csv' | 'pdf' | 'json';
|
||||
dateRange: DateRange;
|
||||
sections: string[];
|
||||
includeCharts: boolean;
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
data: string;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
// Dashboard configuration
|
||||
export interface DashboardConfig {
|
||||
refreshInterval: number;
|
||||
defaultTimeRange: TimeRange;
|
||||
visibleWidgets: string[];
|
||||
layout: 'grid' | 'list';
|
||||
}
|
||||
|
||||
// KPI Card configuration
|
||||
export interface KPICardData {
|
||||
id: string;
|
||||
title: string;
|
||||
value: number | string;
|
||||
unit?: string;
|
||||
change?: number;
|
||||
changePercent?: number;
|
||||
trend: TrendDirection;
|
||||
color: 'green' | 'blue' | 'purple' | 'orange' | 'red' | 'teal';
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
// Chart configuration
|
||||
export interface ChartConfig {
|
||||
title: string;
|
||||
type: 'line' | 'bar' | 'pie' | 'area' | 'heatmap' | 'gauge';
|
||||
data: any[];
|
||||
xKey?: string;
|
||||
yKey?: string;
|
||||
colors?: string[];
|
||||
showLegend?: boolean;
|
||||
showGrid?: boolean;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Filter state for analytics
|
||||
export interface AnalyticsFilters {
|
||||
timeRange: TimeRange;
|
||||
dateRange?: DateRange;
|
||||
species?: string[];
|
||||
regions?: string[];
|
||||
transportMethods?: string[];
|
||||
farmIds?: string[];
|
||||
}
|
||||
|
||||
// Aggregation types
|
||||
export type AggregationType = 'sum' | 'avg' | 'min' | 'max' | 'count';
|
||||
export type GroupByPeriod = 'hour' | 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
export interface AggregationConfig {
|
||||
metric: string;
|
||||
aggregation: AggregationType;
|
||||
groupBy?: GroupByPeriod;
|
||||
filters?: Record<string, any>;
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ export class PlantChain {
|
|||
private plantIndex: Map<string, PlantBlock>; // Quick lookup by plant ID
|
||||
|
||||
constructor(difficulty: number = 4) {
|
||||
this.chain = [this.createGenesisBlock()];
|
||||
this.difficulty = difficulty;
|
||||
this.plantIndex = new Map();
|
||||
this.chain = [this.createGenesisBlock()];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,15 +2,6 @@
|
|||
|
||||
import { GrowingEnvironment, GrowthMetrics } from '../environment/types';
|
||||
|
||||
// Re-export types from environment
|
||||
export type { GrowingEnvironment, GrowthMetrics };
|
||||
|
||||
// Re-export PlantBlock class
|
||||
export { PlantBlock } from './PlantBlock';
|
||||
|
||||
// Propagation type alias
|
||||
export type PropagationType = 'seed' | 'clone' | 'cutting' | 'division' | 'grafting' | 'original';
|
||||
|
||||
export interface PlantLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export interface PlantingRecommendation {
|
|||
|
||||
// Quantities
|
||||
recommendedQuantity: number;
|
||||
quantityUnit: 'plants' | 'seeds' | 'kg_expected_yield' | 'sqm';
|
||||
quantityUnit: 'plants' | 'seeds' | 'kg_expected_yield';
|
||||
expectedYieldKg: number;
|
||||
yieldConfidence: number; // 0-100
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
// Marketplace Module Index
|
||||
// Re-exports all marketplace services and types
|
||||
|
||||
export * from './types';
|
||||
export { listingService } from './listingService';
|
||||
export { offerService } from './offerService';
|
||||
export { searchService } from './searchService';
|
||||
export { matchingService } from './matchingService';
|
||||
export {
|
||||
listingStore,
|
||||
offerStore,
|
||||
sellerProfileStore,
|
||||
wishlistStore,
|
||||
generateId,
|
||||
} from './store';
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
// Listing Service for Marketplace
|
||||
// Handles CRUD operations for marketplace listings
|
||||
|
||||
import {
|
||||
Listing,
|
||||
ListingStatus,
|
||||
CreateListingInput,
|
||||
UpdateListingInput,
|
||||
} from './types';
|
||||
import { listingStore, generateId } from './store';
|
||||
|
||||
export class ListingService {
|
||||
/**
|
||||
* Create a new listing
|
||||
*/
|
||||
async createListing(
|
||||
sellerId: string,
|
||||
sellerName: string,
|
||||
input: CreateListingInput
|
||||
): Promise<Listing> {
|
||||
const listing: Listing = {
|
||||
id: generateId(),
|
||||
sellerId,
|
||||
sellerName,
|
||||
plantId: input.plantId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
price: input.price,
|
||||
currency: input.currency || 'USD',
|
||||
quantity: input.quantity,
|
||||
category: input.category,
|
||||
status: ListingStatus.DRAFT,
|
||||
location: input.location,
|
||||
tags: input.tags || [],
|
||||
images: [],
|
||||
viewCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
expiresAt: input.expiresAt,
|
||||
};
|
||||
|
||||
return listingStore.create(listing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing by ID
|
||||
*/
|
||||
async getListingById(id: string): Promise<Listing | null> {
|
||||
const listing = listingStore.getById(id);
|
||||
return listing || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing and increment view count
|
||||
*/
|
||||
async viewListing(id: string): Promise<Listing | null> {
|
||||
listingStore.incrementViewCount(id);
|
||||
return this.getListingById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all listings by seller
|
||||
*/
|
||||
async getListingsBySeller(sellerId: string): Promise<Listing[]> {
|
||||
return listingStore.getBySellerId(sellerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active listings
|
||||
*/
|
||||
async getActiveListings(): Promise<Listing[]> {
|
||||
return listingStore.getAll().filter(l => l.status === ListingStatus.ACTIVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a listing
|
||||
*/
|
||||
async updateListing(
|
||||
id: string,
|
||||
sellerId: string,
|
||||
updates: UpdateListingInput
|
||||
): Promise<Listing | null> {
|
||||
const listing = listingStore.getById(id);
|
||||
|
||||
if (!listing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
const updated = listingStore.update(id, updates);
|
||||
return updated || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a draft listing (make it active)
|
||||
*/
|
||||
async publishListing(id: string, sellerId: string): Promise<Listing | null> {
|
||||
const listing = listingStore.getById(id);
|
||||
|
||||
if (!listing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
if (listing.status !== ListingStatus.DRAFT) {
|
||||
throw new Error('Only draft listings can be published');
|
||||
}
|
||||
|
||||
return listingStore.update(id, { status: ListingStatus.ACTIVE }) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a listing
|
||||
*/
|
||||
async cancelListing(id: string, sellerId: string): Promise<Listing | null> {
|
||||
const listing = listingStore.getById(id);
|
||||
|
||||
if (!listing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
if (listing.status === ListingStatus.SOLD) {
|
||||
throw new Error('Cannot cancel a sold listing');
|
||||
}
|
||||
|
||||
return listingStore.update(id, { status: ListingStatus.CANCELLED }) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark listing as sold
|
||||
*/
|
||||
async markAsSold(id: string, sellerId: string): Promise<Listing | null> {
|
||||
const listing = listingStore.getById(id);
|
||||
|
||||
if (!listing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
return listingStore.update(id, {
|
||||
status: ListingStatus.SOLD,
|
||||
quantity: 0
|
||||
}) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a listing (only drafts or cancelled)
|
||||
*/
|
||||
async deleteListing(id: string, sellerId: string): Promise<boolean> {
|
||||
const listing = listingStore.getById(id);
|
||||
|
||||
if (!listing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
if (listing.status === ListingStatus.ACTIVE || listing.status === ListingStatus.SOLD) {
|
||||
throw new Error('Cannot delete active or sold listings. Cancel first.');
|
||||
}
|
||||
|
||||
return listingStore.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get listing statistics for a seller
|
||||
*/
|
||||
async getSellerStats(sellerId: string): Promise<{
|
||||
totalListings: number;
|
||||
activeListings: number;
|
||||
soldListings: number;
|
||||
totalViews: number;
|
||||
averagePrice: number;
|
||||
}> {
|
||||
const listings = listingStore.getBySellerId(sellerId);
|
||||
|
||||
const activeListings = listings.filter(l => l.status === ListingStatus.ACTIVE);
|
||||
const soldListings = listings.filter(l => l.status === ListingStatus.SOLD);
|
||||
|
||||
const totalViews = listings.reduce((sum, l) => sum + l.viewCount, 0);
|
||||
const averagePrice = listings.length > 0
|
||||
? listings.reduce((sum, l) => sum + l.price, 0) / listings.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalListings: listings.length,
|
||||
activeListings: activeListings.length,
|
||||
soldListings: soldListings.length,
|
||||
totalViews,
|
||||
averagePrice: Math.round(averagePrice * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and expire old listings
|
||||
*/
|
||||
async expireOldListings(): Promise<number> {
|
||||
const now = new Date();
|
||||
let expiredCount = 0;
|
||||
|
||||
const activeListings = listingStore.getAll().filter(
|
||||
l => l.status === ListingStatus.ACTIVE && l.expiresAt
|
||||
);
|
||||
|
||||
for (const listing of activeListings) {
|
||||
if (listing.expiresAt && listing.expiresAt < now) {
|
||||
listingStore.update(listing.id, { status: ListingStatus.EXPIRED });
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const listingService = new ListingService();
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
// Matching Service for Marketplace
|
||||
// Matches buyers with sellers based on preferences and listings
|
||||
|
||||
import { Listing, ListingCategory, ListingStatus } from './types';
|
||||
import { listingStore, sellerProfileStore } from './store';
|
||||
|
||||
export interface BuyerPreferences {
|
||||
categories: ListingCategory[];
|
||||
maxPrice?: number;
|
||||
preferredLocation?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
maxDistanceKm: number;
|
||||
};
|
||||
preferredTags?: string[];
|
||||
preferVerifiedSellers?: boolean;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
listing: Listing;
|
||||
score: number;
|
||||
matchReasons: string[];
|
||||
}
|
||||
|
||||
export class MatchingService {
|
||||
/**
|
||||
* Find listings that match buyer preferences
|
||||
*/
|
||||
async findMatchesForBuyer(
|
||||
buyerId: string,
|
||||
preferences: BuyerPreferences,
|
||||
limit: number = 10
|
||||
): Promise<MatchResult[]> {
|
||||
const activeListings = listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== buyerId);
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const listing of activeListings) {
|
||||
const { score, reasons } = this.calculateMatchScore(listing, preferences);
|
||||
|
||||
if (score > 0) {
|
||||
matches.push({
|
||||
listing,
|
||||
score,
|
||||
matchReasons: reasons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
matches.sort((a, b) => b.score - a.score);
|
||||
|
||||
return matches.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find buyers who might be interested in a listing
|
||||
* (In a real system, this would query user preferences)
|
||||
*/
|
||||
async findPotentialBuyers(listingId: string): Promise<string[]> {
|
||||
// Placeholder - would match against stored buyer preferences
|
||||
// For now, return empty array as we don't have buyer preference storage
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended listings for a user based on their history
|
||||
*/
|
||||
async getRecommendedListings(
|
||||
userId: string,
|
||||
limit: number = 8
|
||||
): Promise<Listing[]> {
|
||||
// Get user's purchase history and viewed listings
|
||||
// For now, return featured listings as recommendations
|
||||
const activeListings = listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE && l.sellerId !== userId);
|
||||
|
||||
// Sort by a combination of view count and recency
|
||||
return activeListings
|
||||
.sort((a, b) => {
|
||||
const aScore = a.viewCount * 0.7 + this.recencyScore(a.createdAt) * 0.3;
|
||||
const bScore = b.viewCount * 0.7 + this.recencyScore(b.createdAt) * 0.3;
|
||||
return bScore - aScore;
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sellers with good ratings in a category
|
||||
*/
|
||||
async findTopSellersInCategory(
|
||||
category: ListingCategory,
|
||||
limit: number = 5
|
||||
): Promise<{ userId: string; displayName: string; rating: number; listingCount: number }[]> {
|
||||
// Get all active listings in category
|
||||
const categoryListings = listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category);
|
||||
|
||||
// Group by seller
|
||||
const sellerStats: Record<string, { count: number; sellerId: string }> = {};
|
||||
|
||||
for (const listing of categoryListings) {
|
||||
if (!sellerStats[listing.sellerId]) {
|
||||
sellerStats[listing.sellerId] = { count: 0, sellerId: listing.sellerId };
|
||||
}
|
||||
sellerStats[listing.sellerId].count++;
|
||||
}
|
||||
|
||||
// Get seller profiles and sort by rating
|
||||
const topSellers = Object.values(sellerStats)
|
||||
.map(stat => {
|
||||
const profile = sellerProfileStore.getByUserId(stat.sellerId);
|
||||
return {
|
||||
userId: stat.sellerId,
|
||||
displayName: profile?.displayName || 'Unknown Seller',
|
||||
rating: profile?.rating || 0,
|
||||
listingCount: stat.count,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.rating - a.rating)
|
||||
.slice(0, limit);
|
||||
|
||||
return topSellers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate match score between a listing and buyer preferences
|
||||
*/
|
||||
private calculateMatchScore(
|
||||
listing: Listing,
|
||||
preferences: BuyerPreferences
|
||||
): { score: number; reasons: string[] } {
|
||||
let score = 0;
|
||||
const reasons: string[] = [];
|
||||
|
||||
// Category match (highest weight)
|
||||
if (preferences.categories.includes(listing.category)) {
|
||||
score += 40;
|
||||
reasons.push(`Matches preferred category: ${listing.category}`);
|
||||
}
|
||||
|
||||
// Price within budget
|
||||
if (preferences.maxPrice && listing.price <= preferences.maxPrice) {
|
||||
score += 20;
|
||||
reasons.push('Within budget');
|
||||
}
|
||||
|
||||
// Location match
|
||||
if (preferences.preferredLocation && listing.location) {
|
||||
const distance = this.calculateDistance(
|
||||
preferences.preferredLocation.lat,
|
||||
preferences.preferredLocation.lng,
|
||||
listing.location.lat,
|
||||
listing.location.lng
|
||||
);
|
||||
|
||||
if (distance <= preferences.preferredLocation.maxDistanceKm) {
|
||||
score += 25;
|
||||
reasons.push(`Nearby (${Math.round(distance)}km away)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Tag matches
|
||||
if (preferences.preferredTags && preferences.preferredTags.length > 0) {
|
||||
const matchingTags = listing.tags.filter(tag =>
|
||||
preferences.preferredTags!.includes(tag.toLowerCase())
|
||||
);
|
||||
if (matchingTags.length > 0) {
|
||||
score += matchingTags.length * 5;
|
||||
reasons.push(`Matching tags: ${matchingTags.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Verified seller preference
|
||||
if (preferences.preferVerifiedSellers) {
|
||||
const profile = sellerProfileStore.getByUserId(listing.sellerId);
|
||||
if (profile?.verified) {
|
||||
score += 10;
|
||||
reasons.push('Verified seller');
|
||||
}
|
||||
}
|
||||
|
||||
return { score, reasons };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate recency score (0-100, higher for more recent)
|
||||
*/
|
||||
private recencyScore(date: Date): number {
|
||||
const now = Date.now();
|
||||
const created = date.getTime();
|
||||
const daysOld = (now - created) / (1000 * 60 * 60 * 24);
|
||||
|
||||
// Exponential decay: 100 for today, ~37 for 7 days, ~14 for 14 days
|
||||
return 100 * Math.exp(-daysOld / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points (Haversine formula)
|
||||
*/
|
||||
private calculateDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = this.toRad(lat2 - lat1);
|
||||
const dLng = this.toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRad(lat1)) *
|
||||
Math.cos(this.toRad(lat2)) *
|
||||
Math.sin(dLng / 2) *
|
||||
Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
private toRad(deg: number): number {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const matchingService = new MatchingService();
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
// Offer Service for Marketplace
|
||||
// Handles offer management for marketplace listings
|
||||
|
||||
import {
|
||||
Offer,
|
||||
OfferStatus,
|
||||
Listing,
|
||||
ListingStatus,
|
||||
CreateOfferInput,
|
||||
} from './types';
|
||||
import { offerStore, listingStore, generateId } from './store';
|
||||
|
||||
export class OfferService {
|
||||
/**
|
||||
* Create a new offer on a listing
|
||||
*/
|
||||
async createOffer(
|
||||
buyerId: string,
|
||||
buyerName: string,
|
||||
input: CreateOfferInput
|
||||
): Promise<Offer> {
|
||||
const listing = listingStore.getById(input.listingId);
|
||||
|
||||
if (!listing) {
|
||||
throw new Error('Listing not found');
|
||||
}
|
||||
|
||||
if (listing.status !== ListingStatus.ACTIVE) {
|
||||
throw new Error('Cannot make offers on inactive listings');
|
||||
}
|
||||
|
||||
if (listing.sellerId === buyerId) {
|
||||
throw new Error('Cannot make an offer on your own listing');
|
||||
}
|
||||
|
||||
// Check for existing pending offer from this buyer
|
||||
const existingOffer = offerStore.getByListingId(input.listingId)
|
||||
.find(o => o.buyerId === buyerId && o.status === OfferStatus.PENDING);
|
||||
|
||||
if (existingOffer) {
|
||||
throw new Error('You already have a pending offer on this listing');
|
||||
}
|
||||
|
||||
const offer: Offer = {
|
||||
id: generateId(),
|
||||
listingId: input.listingId,
|
||||
buyerId,
|
||||
buyerName,
|
||||
amount: input.amount,
|
||||
message: input.message,
|
||||
status: OfferStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
};
|
||||
|
||||
return offerStore.create(offer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an offer by ID
|
||||
*/
|
||||
async getOfferById(id: string): Promise<Offer | null> {
|
||||
return offerStore.getById(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offers for a listing
|
||||
*/
|
||||
async getOffersForListing(listingId: string): Promise<Offer[]> {
|
||||
return offerStore.getByListingId(listingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offers made by a buyer
|
||||
*/
|
||||
async getOffersByBuyer(buyerId: string): Promise<Offer[]> {
|
||||
return offerStore.getByBuyerId(buyerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all offers for a seller's listings
|
||||
*/
|
||||
async getOffersForSeller(sellerId: string): Promise<(Offer & { listing?: Listing })[]> {
|
||||
const sellerListings = listingStore.getBySellerId(sellerId);
|
||||
const listingIds = new Set(sellerListings.map(l => l.id));
|
||||
|
||||
const offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
|
||||
|
||||
return offers.map(offer => ({
|
||||
...offer,
|
||||
listing: listingStore.getById(offer.listingId),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an offer
|
||||
*/
|
||||
async acceptOffer(offerId: string, sellerId: string): Promise<Offer> {
|
||||
const offer = offerStore.getById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
const listing = listingStore.getById(offer.listingId);
|
||||
|
||||
if (!listing) {
|
||||
throw new Error('Listing not found');
|
||||
}
|
||||
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
if (offer.status !== OfferStatus.PENDING) {
|
||||
throw new Error('Can only accept pending offers');
|
||||
}
|
||||
|
||||
// Accept this offer
|
||||
const acceptedOffer = offerStore.update(offerId, {
|
||||
status: OfferStatus.ACCEPTED
|
||||
});
|
||||
|
||||
// Reject all other pending offers for this listing
|
||||
const otherOffers = offerStore.getByListingId(offer.listingId)
|
||||
.filter(o => o.id !== offerId && o.status === OfferStatus.PENDING);
|
||||
|
||||
for (const otherOffer of otherOffers) {
|
||||
offerStore.update(otherOffer.id, { status: OfferStatus.REJECTED });
|
||||
}
|
||||
|
||||
// Mark listing as sold
|
||||
listingStore.update(offer.listingId, {
|
||||
status: ListingStatus.SOLD,
|
||||
quantity: 0
|
||||
});
|
||||
|
||||
if (!acceptedOffer) {
|
||||
throw new Error('Failed to accept offer');
|
||||
}
|
||||
|
||||
return acceptedOffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an offer
|
||||
*/
|
||||
async rejectOffer(offerId: string, sellerId: string): Promise<Offer> {
|
||||
const offer = offerStore.getById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
const listing = listingStore.getById(offer.listingId);
|
||||
|
||||
if (!listing) {
|
||||
throw new Error('Listing not found');
|
||||
}
|
||||
|
||||
if (listing.sellerId !== sellerId) {
|
||||
throw new Error('Unauthorized: You do not own this listing');
|
||||
}
|
||||
|
||||
if (offer.status !== OfferStatus.PENDING) {
|
||||
throw new Error('Can only reject pending offers');
|
||||
}
|
||||
|
||||
const rejectedOffer = offerStore.update(offerId, {
|
||||
status: OfferStatus.REJECTED
|
||||
});
|
||||
|
||||
if (!rejectedOffer) {
|
||||
throw new Error('Failed to reject offer');
|
||||
}
|
||||
|
||||
return rejectedOffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withdraw an offer (buyer action)
|
||||
*/
|
||||
async withdrawOffer(offerId: string, buyerId: string): Promise<Offer> {
|
||||
const offer = offerStore.getById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
if (offer.buyerId !== buyerId) {
|
||||
throw new Error('Unauthorized: This is not your offer');
|
||||
}
|
||||
|
||||
if (offer.status !== OfferStatus.PENDING) {
|
||||
throw new Error('Can only withdraw pending offers');
|
||||
}
|
||||
|
||||
const withdrawnOffer = offerStore.update(offerId, {
|
||||
status: OfferStatus.WITHDRAWN
|
||||
});
|
||||
|
||||
if (!withdrawnOffer) {
|
||||
throw new Error('Failed to withdraw offer');
|
||||
}
|
||||
|
||||
return withdrawnOffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counter offer (update amount)
|
||||
*/
|
||||
async updateOfferAmount(
|
||||
offerId: string,
|
||||
buyerId: string,
|
||||
newAmount: number
|
||||
): Promise<Offer> {
|
||||
const offer = offerStore.getById(offerId);
|
||||
|
||||
if (!offer) {
|
||||
throw new Error('Offer not found');
|
||||
}
|
||||
|
||||
if (offer.buyerId !== buyerId) {
|
||||
throw new Error('Unauthorized: This is not your offer');
|
||||
}
|
||||
|
||||
if (offer.status !== OfferStatus.PENDING) {
|
||||
throw new Error('Can only update pending offers');
|
||||
}
|
||||
|
||||
const updatedOffer = offerStore.update(offerId, { amount: newAmount });
|
||||
|
||||
if (!updatedOffer) {
|
||||
throw new Error('Failed to update offer');
|
||||
}
|
||||
|
||||
return updatedOffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get offer statistics
|
||||
*/
|
||||
async getOfferStats(userId: string, role: 'buyer' | 'seller'): Promise<{
|
||||
totalOffers: number;
|
||||
pendingOffers: number;
|
||||
acceptedOffers: number;
|
||||
rejectedOffers: number;
|
||||
}> {
|
||||
let offers: Offer[];
|
||||
|
||||
if (role === 'buyer') {
|
||||
offers = offerStore.getByBuyerId(userId);
|
||||
} else {
|
||||
const sellerListings = listingStore.getBySellerId(userId);
|
||||
const listingIds = new Set(sellerListings.map(l => l.id));
|
||||
offers = offerStore.getAll().filter(o => listingIds.has(o.listingId));
|
||||
}
|
||||
|
||||
return {
|
||||
totalOffers: offers.length,
|
||||
pendingOffers: offers.filter(o => o.status === OfferStatus.PENDING).length,
|
||||
acceptedOffers: offers.filter(o => o.status === OfferStatus.ACCEPTED).length,
|
||||
rejectedOffers: offers.filter(o => o.status === OfferStatus.REJECTED).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire old pending offers
|
||||
*/
|
||||
async expireOldOffers(): Promise<number> {
|
||||
const now = new Date();
|
||||
let expiredCount = 0;
|
||||
|
||||
const pendingOffers = offerStore.getAll().filter(
|
||||
o => o.status === OfferStatus.PENDING && o.expiresAt
|
||||
);
|
||||
|
||||
for (const offer of pendingOffers) {
|
||||
if (offer.expiresAt && offer.expiresAt < now) {
|
||||
offerStore.update(offer.id, { status: OfferStatus.EXPIRED });
|
||||
expiredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const offerService = new OfferService();
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
// Search Service for Marketplace
|
||||
// Handles listing search and filtering
|
||||
|
||||
import {
|
||||
Listing,
|
||||
ListingStatus,
|
||||
SearchFilters,
|
||||
SearchResult,
|
||||
ListingCategory,
|
||||
MarketplaceStats,
|
||||
} from './types';
|
||||
import { listingStore } from './store';
|
||||
|
||||
export class SearchService {
|
||||
/**
|
||||
* Search listings with filters
|
||||
*/
|
||||
async searchListings(filters: SearchFilters): Promise<SearchResult> {
|
||||
let listings = listingStore.getAll();
|
||||
|
||||
// Only show active listings unless specific status requested
|
||||
if (filters.status) {
|
||||
listings = listings.filter(l => l.status === filters.status);
|
||||
} else {
|
||||
listings = listings.filter(l => l.status === ListingStatus.ACTIVE);
|
||||
}
|
||||
|
||||
// Text search in title and description
|
||||
if (filters.query) {
|
||||
const query = filters.query.toLowerCase();
|
||||
listings = listings.filter(l =>
|
||||
l.title.toLowerCase().includes(query) ||
|
||||
l.description.toLowerCase().includes(query) ||
|
||||
l.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (filters.category) {
|
||||
listings = listings.filter(l => l.category === filters.category);
|
||||
}
|
||||
|
||||
// Price range filter
|
||||
if (filters.minPrice !== undefined) {
|
||||
listings = listings.filter(l => l.price >= filters.minPrice!);
|
||||
}
|
||||
if (filters.maxPrice !== undefined) {
|
||||
listings = listings.filter(l => l.price <= filters.maxPrice!);
|
||||
}
|
||||
|
||||
// Seller filter
|
||||
if (filters.sellerId) {
|
||||
listings = listings.filter(l => l.sellerId === filters.sellerId);
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
const filterTags = filters.tags.map(t => t.toLowerCase());
|
||||
listings = listings.filter(l =>
|
||||
filterTags.some(tag => l.tags.map(t => t.toLowerCase()).includes(tag))
|
||||
);
|
||||
}
|
||||
|
||||
// Location filter (approximate distance)
|
||||
if (filters.location) {
|
||||
listings = listings.filter(l => {
|
||||
if (!l.location) return false;
|
||||
const distance = this.calculateDistance(
|
||||
filters.location!.lat,
|
||||
filters.location!.lng,
|
||||
l.location.lat,
|
||||
l.location.lng
|
||||
);
|
||||
return distance <= filters.location!.radiusKm;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort listings
|
||||
listings = this.sortListings(listings, filters.sortBy || 'date_desc', filters.query);
|
||||
|
||||
// Pagination
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const total = listings.length;
|
||||
const startIndex = (page - 1) * limit;
|
||||
const paginatedListings = listings.slice(startIndex, startIndex + limit);
|
||||
|
||||
return {
|
||||
listings: paginatedListings,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: startIndex + limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured listings (most viewed active listings)
|
||||
*/
|
||||
async getFeaturedListings(limit: number = 6): Promise<Listing[]> {
|
||||
return listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE)
|
||||
.sort((a, b) => b.viewCount - a.viewCount)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent listings
|
||||
*/
|
||||
async getRecentListings(limit: number = 10): Promise<Listing[]> {
|
||||
return listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get listings by category
|
||||
*/
|
||||
async getListingsByCategory(
|
||||
category: ListingCategory,
|
||||
limit: number = 20
|
||||
): Promise<Listing[]> {
|
||||
return listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE && l.category === category)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get similar listings based on category and tags
|
||||
*/
|
||||
async getSimilarListings(listingId: string, limit: number = 4): Promise<Listing[]> {
|
||||
const listing = listingStore.getById(listingId);
|
||||
if (!listing) return [];
|
||||
|
||||
return listingStore
|
||||
.getAll()
|
||||
.filter(l =>
|
||||
l.id !== listingId &&
|
||||
l.status === ListingStatus.ACTIVE &&
|
||||
(l.category === listing.category ||
|
||||
l.tags.some(tag => listing.tags.includes(tag)))
|
||||
)
|
||||
.sort((a, b) => {
|
||||
// Score based on matching tags
|
||||
const aScore = a.tags.filter(tag => listing.tags.includes(tag)).length;
|
||||
const bScore = b.tags.filter(tag => listing.tags.includes(tag)).length;
|
||||
return bScore - aScore;
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get marketplace statistics
|
||||
*/
|
||||
async getMarketplaceStats(): Promise<MarketplaceStats> {
|
||||
const allListings = listingStore.getAll();
|
||||
const activeListings = allListings.filter(l => l.status === ListingStatus.ACTIVE);
|
||||
const soldListings = allListings.filter(l => l.status === ListingStatus.SOLD);
|
||||
|
||||
// Category counts
|
||||
const categoryCounts: Record<ListingCategory, number> = {
|
||||
[ListingCategory.SEEDS]: 0,
|
||||
[ListingCategory.SEEDLINGS]: 0,
|
||||
[ListingCategory.MATURE_PLANTS]: 0,
|
||||
[ListingCategory.CUTTINGS]: 0,
|
||||
[ListingCategory.PRODUCE]: 0,
|
||||
[ListingCategory.SUPPLIES]: 0,
|
||||
};
|
||||
|
||||
activeListings.forEach(l => {
|
||||
categoryCounts[l.category]++;
|
||||
});
|
||||
|
||||
// Top categories
|
||||
const topCategories = Object.entries(categoryCounts)
|
||||
.map(([category, count]) => ({
|
||||
category: category as ListingCategory,
|
||||
count
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
|
||||
// Average price
|
||||
const averagePrice = activeListings.length > 0
|
||||
? activeListings.reduce((sum, l) => sum + l.price, 0) / activeListings.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalListings: allListings.length,
|
||||
activeListings: activeListings.length,
|
||||
totalSales: soldListings.length,
|
||||
totalOffers: 0, // Would be calculated from offer store
|
||||
categoryCounts,
|
||||
averagePrice: Math.round(averagePrice * 100) / 100,
|
||||
topCategories,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular tags
|
||||
*/
|
||||
async getPopularTags(limit: number = 20): Promise<{ tag: string; count: number }[]> {
|
||||
const tagCounts: Record<string, number> = {};
|
||||
|
||||
listingStore
|
||||
.getAll()
|
||||
.filter(l => l.status === ListingStatus.ACTIVE)
|
||||
.forEach(l => {
|
||||
l.tags.forEach(tag => {
|
||||
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||
});
|
||||
});
|
||||
|
||||
return Object.entries(tagCounts)
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort listings based on sort option
|
||||
*/
|
||||
private sortListings(
|
||||
listings: Listing[],
|
||||
sortBy: string,
|
||||
query?: string
|
||||
): Listing[] {
|
||||
switch (sortBy) {
|
||||
case 'price_asc':
|
||||
return listings.sort((a, b) => a.price - b.price);
|
||||
case 'price_desc':
|
||||
return listings.sort((a, b) => b.price - a.price);
|
||||
case 'date_asc':
|
||||
return listings.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||
case 'date_desc':
|
||||
return listings.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
case 'relevance':
|
||||
if (!query) {
|
||||
return listings.sort((a, b) => b.viewCount - a.viewCount);
|
||||
}
|
||||
// Simple relevance scoring based on title match
|
||||
return listings.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
|
||||
const bTitle = b.title.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
|
||||
return bTitle - aTitle;
|
||||
});
|
||||
default:
|
||||
return listings;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points (Haversine formula)
|
||||
*/
|
||||
private calculateDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = this.toRad(lat2 - lat1);
|
||||
const dLng = this.toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(this.toRad(lat1)) *
|
||||
Math.cos(this.toRad(lat2)) *
|
||||
Math.sin(dLng / 2) *
|
||||
Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
private toRad(deg: number): number {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const searchService = new SearchService();
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
// In-Memory Store for Marketplace
|
||||
// This will be replaced with Prisma database calls once Agent 2 completes database setup
|
||||
|
||||
import {
|
||||
Listing,
|
||||
Offer,
|
||||
SellerProfile,
|
||||
WishlistItem,
|
||||
ListingCategory,
|
||||
ListingStatus,
|
||||
OfferStatus,
|
||||
} from './types';
|
||||
|
||||
// In-memory storage
|
||||
const listings: Map<string, Listing> = new Map();
|
||||
const offers: Map<string, Offer> = new Map();
|
||||
const sellerProfiles: Map<string, SellerProfile> = new Map();
|
||||
const wishlistItems: Map<string, WishlistItem> = new Map();
|
||||
|
||||
// Helper to generate IDs
|
||||
export const generateId = (): string => {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// Seed with sample data for development
|
||||
const seedSampleData = () => {
|
||||
const sampleListings: Listing[] = [
|
||||
{
|
||||
id: 'listing-1',
|
||||
sellerId: 'user-1',
|
||||
sellerName: 'Green Thumb Gardens',
|
||||
title: 'Heirloom Tomato Seedlings - Cherokee Purple',
|
||||
description: 'Beautiful heirloom Cherokee Purple tomato seedlings, organically grown. These produce large, deep purple-red fruits with rich, complex flavor. Perfect for home gardens.',
|
||||
price: 4.99,
|
||||
currency: 'USD',
|
||||
quantity: 24,
|
||||
category: ListingCategory.SEEDLINGS,
|
||||
status: ListingStatus.ACTIVE,
|
||||
location: { lat: 40.7128, lng: -74.006, city: 'New York', region: 'NY' },
|
||||
tags: ['organic', 'heirloom', 'tomato', 'vegetable'],
|
||||
images: [
|
||||
{ id: 'img-1', listingId: 'listing-1', url: '/images/tomato-seedling.jpg', alt: 'Cherokee Purple Tomato Seedling', isPrimary: true, createdAt: new Date() }
|
||||
],
|
||||
viewCount: 142,
|
||||
createdAt: new Date('2024-03-01'),
|
||||
updatedAt: new Date('2024-03-15'),
|
||||
},
|
||||
{
|
||||
id: 'listing-2',
|
||||
sellerId: 'user-2',
|
||||
sellerName: 'Urban Herb Farm',
|
||||
title: 'Fresh Basil Plants - Genovese',
|
||||
description: 'Ready-to-harvest Genovese basil plants grown in our vertical farm. Perfect for pesto, salads, and Italian cuisine. Each plant is 6-8 inches tall.',
|
||||
price: 6.50,
|
||||
currency: 'USD',
|
||||
quantity: 50,
|
||||
category: ListingCategory.MATURE_PLANTS,
|
||||
status: ListingStatus.ACTIVE,
|
||||
location: { lat: 34.0522, lng: -118.2437, city: 'Los Angeles', region: 'CA' },
|
||||
tags: ['herbs', 'basil', 'culinary', 'fresh'],
|
||||
images: [],
|
||||
viewCount: 89,
|
||||
createdAt: new Date('2024-03-10'),
|
||||
updatedAt: new Date('2024-03-10'),
|
||||
},
|
||||
{
|
||||
id: 'listing-3',
|
||||
sellerId: 'user-1',
|
||||
sellerName: 'Green Thumb Gardens',
|
||||
title: 'Organic Lettuce Mix Seeds',
|
||||
description: 'Premium mix of organic lettuce seeds including romaine, butterhead, and red leaf varieties. Perfect for succession planting.',
|
||||
price: 3.99,
|
||||
currency: 'USD',
|
||||
quantity: 100,
|
||||
category: ListingCategory.SEEDS,
|
||||
status: ListingStatus.ACTIVE,
|
||||
location: { lat: 40.7128, lng: -74.006, city: 'New York', region: 'NY' },
|
||||
tags: ['organic', 'seeds', 'lettuce', 'salad'],
|
||||
images: [],
|
||||
viewCount: 256,
|
||||
createdAt: new Date('2024-02-15'),
|
||||
updatedAt: new Date('2024-03-01'),
|
||||
},
|
||||
{
|
||||
id: 'listing-4',
|
||||
sellerId: 'user-3',
|
||||
sellerName: 'Succulent Paradise',
|
||||
title: 'Assorted Succulent Cuttings - 10 Pack',
|
||||
description: 'Beautiful assortment of succulent cuttings ready for propagation. Includes echeveria, sedum, and crassula varieties.',
|
||||
price: 15.00,
|
||||
currency: 'USD',
|
||||
quantity: 30,
|
||||
category: ListingCategory.CUTTINGS,
|
||||
status: ListingStatus.ACTIVE,
|
||||
location: { lat: 33.4484, lng: -112.074, city: 'Phoenix', region: 'AZ' },
|
||||
tags: ['succulents', 'cuttings', 'propagation', 'drought-tolerant'],
|
||||
images: [],
|
||||
viewCount: 178,
|
||||
createdAt: new Date('2024-03-05'),
|
||||
updatedAt: new Date('2024-03-12'),
|
||||
},
|
||||
{
|
||||
id: 'listing-5',
|
||||
sellerId: 'user-2',
|
||||
sellerName: 'Urban Herb Farm',
|
||||
title: 'Fresh Microgreens - Chef\'s Mix',
|
||||
description: 'Freshly harvested microgreens mix including sunflower, radish, and pea shoots. Harvested same day as shipping for maximum freshness.',
|
||||
price: 8.99,
|
||||
currency: 'USD',
|
||||
quantity: 40,
|
||||
category: ListingCategory.PRODUCE,
|
||||
status: ListingStatus.ACTIVE,
|
||||
location: { lat: 34.0522, lng: -118.2437, city: 'Los Angeles', region: 'CA' },
|
||||
tags: ['microgreens', 'fresh', 'produce', 'chef'],
|
||||
images: [],
|
||||
viewCount: 312,
|
||||
createdAt: new Date('2024-03-18'),
|
||||
updatedAt: new Date('2024-03-18'),
|
||||
},
|
||||
];
|
||||
|
||||
const sampleProfiles: SellerProfile[] = [
|
||||
{
|
||||
userId: 'user-1',
|
||||
displayName: 'Green Thumb Gardens',
|
||||
bio: 'Family-owned nursery specializing in heirloom vegetables and native plants.',
|
||||
location: { city: 'New York', region: 'NY' },
|
||||
rating: 4.8,
|
||||
reviewCount: 127,
|
||||
totalSales: 523,
|
||||
memberSince: new Date('2023-01-15'),
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
userId: 'user-2',
|
||||
displayName: 'Urban Herb Farm',
|
||||
bio: 'Vertical farm growing fresh herbs and microgreens in the heart of LA.',
|
||||
location: { city: 'Los Angeles', region: 'CA' },
|
||||
rating: 4.9,
|
||||
reviewCount: 89,
|
||||
totalSales: 412,
|
||||
memberSince: new Date('2023-03-20'),
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
userId: 'user-3',
|
||||
displayName: 'Succulent Paradise',
|
||||
bio: 'Desert plant enthusiast sharing the beauty of succulents.',
|
||||
location: { city: 'Phoenix', region: 'AZ' },
|
||||
rating: 4.7,
|
||||
reviewCount: 56,
|
||||
totalSales: 198,
|
||||
memberSince: new Date('2023-06-01'),
|
||||
verified: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Seed listings
|
||||
sampleListings.forEach(listing => {
|
||||
listings.set(listing.id, listing);
|
||||
});
|
||||
|
||||
// Seed profiles
|
||||
sampleProfiles.forEach(profile => {
|
||||
sellerProfiles.set(profile.userId, profile);
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize with sample data
|
||||
seedSampleData();
|
||||
|
||||
// Export store operations
|
||||
export const listingStore = {
|
||||
getAll: (): Listing[] => Array.from(listings.values()),
|
||||
|
||||
getById: (id: string): Listing | undefined => listings.get(id),
|
||||
|
||||
getBySellerId: (sellerId: string): Listing[] =>
|
||||
Array.from(listings.values()).filter(l => l.sellerId === sellerId),
|
||||
|
||||
create: (listing: Listing): Listing => {
|
||||
listings.set(listing.id, listing);
|
||||
return listing;
|
||||
},
|
||||
|
||||
update: (id: string, updates: Partial<Listing>): Listing | undefined => {
|
||||
const existing = listings.get(id);
|
||||
if (!existing) return undefined;
|
||||
const updated = { ...existing, ...updates, updatedAt: new Date() };
|
||||
listings.set(id, updated);
|
||||
return updated;
|
||||
},
|
||||
|
||||
delete: (id: string): boolean => listings.delete(id),
|
||||
|
||||
incrementViewCount: (id: string): void => {
|
||||
const listing = listings.get(id);
|
||||
if (listing) {
|
||||
listing.viewCount++;
|
||||
listings.set(id, listing);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const offerStore = {
|
||||
getAll: (): Offer[] => Array.from(offers.values()),
|
||||
|
||||
getById: (id: string): Offer | undefined => offers.get(id),
|
||||
|
||||
getByListingId: (listingId: string): Offer[] =>
|
||||
Array.from(offers.values()).filter(o => o.listingId === listingId),
|
||||
|
||||
getByBuyerId: (buyerId: string): Offer[] =>
|
||||
Array.from(offers.values()).filter(o => o.buyerId === buyerId),
|
||||
|
||||
create: (offer: Offer): Offer => {
|
||||
offers.set(offer.id, offer);
|
||||
return offer;
|
||||
},
|
||||
|
||||
update: (id: string, updates: Partial<Offer>): Offer | undefined => {
|
||||
const existing = offers.get(id);
|
||||
if (!existing) return undefined;
|
||||
const updated = { ...existing, ...updates, updatedAt: new Date() };
|
||||
offers.set(id, updated);
|
||||
return updated;
|
||||
},
|
||||
|
||||
delete: (id: string): boolean => offers.delete(id),
|
||||
};
|
||||
|
||||
export const sellerProfileStore = {
|
||||
getByUserId: (userId: string): SellerProfile | undefined =>
|
||||
sellerProfiles.get(userId),
|
||||
|
||||
create: (profile: SellerProfile): SellerProfile => {
|
||||
sellerProfiles.set(profile.userId, profile);
|
||||
return profile;
|
||||
},
|
||||
|
||||
update: (userId: string, updates: Partial<SellerProfile>): SellerProfile | undefined => {
|
||||
const existing = sellerProfiles.get(userId);
|
||||
if (!existing) return undefined;
|
||||
const updated = { ...existing, ...updates };
|
||||
sellerProfiles.set(userId, updated);
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
|
||||
export const wishlistStore = {
|
||||
getByUserId: (userId: string): WishlistItem[] =>
|
||||
Array.from(wishlistItems.values()).filter(w => w.userId === userId),
|
||||
|
||||
add: (item: WishlistItem): WishlistItem => {
|
||||
wishlistItems.set(item.id, item);
|
||||
return item;
|
||||
},
|
||||
|
||||
remove: (userId: string, listingId: string): boolean => {
|
||||
const item = Array.from(wishlistItems.values()).find(
|
||||
w => w.userId === userId && w.listingId === listingId
|
||||
);
|
||||
if (item) {
|
||||
return wishlistItems.delete(item.id);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
exists: (userId: string, listingId: string): boolean =>
|
||||
Array.from(wishlistItems.values()).some(
|
||||
w => w.userId === userId && w.listingId === listingId
|
||||
),
|
||||
};
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
// Marketplace Types for LocalGreenChain
|
||||
// These types define the marketplace foundation for plant trading
|
||||
|
||||
export enum ListingCategory {
|
||||
SEEDS = 'seeds',
|
||||
SEEDLINGS = 'seedlings',
|
||||
MATURE_PLANTS = 'mature_plants',
|
||||
CUTTINGS = 'cuttings',
|
||||
PRODUCE = 'produce',
|
||||
SUPPLIES = 'supplies',
|
||||
}
|
||||
|
||||
export enum ListingStatus {
|
||||
DRAFT = 'draft',
|
||||
ACTIVE = 'active',
|
||||
SOLD = 'sold',
|
||||
EXPIRED = 'expired',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum OfferStatus {
|
||||
PENDING = 'pending',
|
||||
ACCEPTED = 'accepted',
|
||||
REJECTED = 'rejected',
|
||||
WITHDRAWN = 'withdrawn',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
export interface ListingImage {
|
||||
id: string;
|
||||
listingId: string;
|
||||
url: string;
|
||||
alt: string;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Listing {
|
||||
id: string;
|
||||
sellerId: string;
|
||||
sellerName?: string;
|
||||
plantId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
category: ListingCategory;
|
||||
status: ListingStatus;
|
||||
location?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
tags: string[];
|
||||
images: ListingImage[];
|
||||
viewCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
id: string;
|
||||
listingId: string;
|
||||
buyerId: string;
|
||||
buyerName?: string;
|
||||
amount: number;
|
||||
message?: string;
|
||||
status: OfferStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface SellerProfile {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
bio?: string;
|
||||
location?: {
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
totalSales: number;
|
||||
memberSince: Date;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface WishlistItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
listingId: string;
|
||||
addedAt: Date;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
query?: string;
|
||||
category?: ListingCategory;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
location?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radiusKm: number;
|
||||
};
|
||||
sellerId?: string;
|
||||
status?: ListingStatus;
|
||||
tags?: string[];
|
||||
sortBy?: 'price_asc' | 'price_desc' | 'date_desc' | 'date_asc' | 'relevance';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
listings: Listing[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface CreateListingInput {
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency?: string;
|
||||
quantity: number;
|
||||
category: ListingCategory;
|
||||
plantId?: string;
|
||||
location?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
city?: string;
|
||||
region?: string;
|
||||
};
|
||||
tags?: string[];
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface UpdateListingInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
price?: number;
|
||||
quantity?: number;
|
||||
status?: ListingStatus;
|
||||
tags?: string[];
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface CreateOfferInput {
|
||||
listingId: string;
|
||||
amount: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface MarketplaceStats {
|
||||
totalListings: number;
|
||||
activeListings: number;
|
||||
totalSales: number;
|
||||
totalOffers: number;
|
||||
categoryCounts: Record<ListingCategory, number>;
|
||||
averagePrice: number;
|
||||
topCategories: { category: ListingCategory; count: number }[];
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
/**
|
||||
* Mobile Camera Utilities
|
||||
* Provides camera access, photo capture, and image processing for mobile devices
|
||||
*/
|
||||
|
||||
export interface CameraConfig {
|
||||
facingMode?: 'user' | 'environment';
|
||||
width?: number;
|
||||
height?: number;
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
export interface CapturedImage {
|
||||
blob: Blob;
|
||||
dataUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class CameraService {
|
||||
private stream: MediaStream | null = null;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
async checkCameraAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.some((device) => device.kind === 'videoinput');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<PermissionState> {
|
||||
try {
|
||||
const result = await navigator.permissions.query({ name: 'camera' as PermissionName });
|
||||
return result.state;
|
||||
} catch {
|
||||
// Fallback: try to access camera to trigger permission prompt
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return 'granted';
|
||||
} catch {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async startCamera(videoElement: HTMLVideoElement, config: CameraConfig = {}): Promise<void> {
|
||||
const {
|
||||
facingMode = 'environment',
|
||||
width = 1280,
|
||||
height = 720,
|
||||
aspectRatio,
|
||||
} = config;
|
||||
|
||||
const constraints: MediaStreamConstraints = {
|
||||
video: {
|
||||
facingMode,
|
||||
width: { ideal: width },
|
||||
height: { ideal: height },
|
||||
...(aspectRatio && { aspectRatio }),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
videoElement.srcObject = this.stream;
|
||||
this.videoElement = videoElement;
|
||||
await videoElement.play();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to start camera: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async capturePhoto(quality = 0.9): Promise<CapturedImage> {
|
||||
if (!this.videoElement || !this.stream) {
|
||||
throw new Error('Camera not started');
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const video = this.videoElement;
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to capture photo'));
|
||||
return;
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||
|
||||
resolve({
|
||||
blob,
|
||||
dataUrl,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async switchCamera(): Promise<void> {
|
||||
if (!this.videoElement) {
|
||||
throw new Error('Camera not started');
|
||||
}
|
||||
|
||||
const currentTrack = this.stream?.getVideoTracks()[0];
|
||||
const currentFacingMode = currentTrack?.getSettings().facingMode;
|
||||
const newFacingMode = currentFacingMode === 'user' ? 'environment' : 'user';
|
||||
|
||||
this.stopCamera();
|
||||
await this.startCamera(this.videoElement, { facingMode: newFacingMode });
|
||||
}
|
||||
|
||||
stopCamera(): void {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
if (this.videoElement) {
|
||||
this.videoElement.srcObject = null;
|
||||
this.videoElement = null;
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.stream !== null && this.stream.active;
|
||||
}
|
||||
}
|
||||
|
||||
// Image processing utilities
|
||||
export async function cropImage(
|
||||
image: CapturedImage,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<CapturedImage> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
const img = await loadImage(image.dataUrl);
|
||||
ctx.drawImage(img, x, y, width, height, 0, 0, width, height);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to crop image'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
blob,
|
||||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function resizeImage(
|
||||
image: CapturedImage,
|
||||
maxWidth: number,
|
||||
maxHeight: number
|
||||
): Promise<CapturedImage> {
|
||||
const img = await loadImage(image.dataUrl);
|
||||
|
||||
let { width, height } = img;
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
|
||||
if (ratio < 1) {
|
||||
width = Math.round(width * ratio);
|
||||
height = Math.round(height * ratio);
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get canvas context');
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to resize image'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
blob,
|
||||
dataUrl: canvas.toDataURL('image/jpeg', 0.9),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let cameraInstance: CameraService | null = null;
|
||||
|
||||
export function getCamera(): CameraService {
|
||||
if (!cameraInstance) {
|
||||
cameraInstance = new CameraService();
|
||||
}
|
||||
return cameraInstance;
|
||||
}
|
||||
|
||||
export default CameraService;
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
/**
|
||||
* Mobile Gesture Utilities
|
||||
* Provides touch gesture detection and handling
|
||||
*/
|
||||
|
||||
export interface GestureEvent {
|
||||
type: 'swipe' | 'pinch' | 'rotate' | 'tap' | 'longpress' | 'doubletap';
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
scale?: number;
|
||||
rotation?: number;
|
||||
center?: { x: number; y: number };
|
||||
velocity?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface GestureHandlers {
|
||||
onSwipe?: (direction: 'up' | 'down' | 'left' | 'right', velocity: number) => void;
|
||||
onPinch?: (scale: number) => void;
|
||||
onRotate?: (angle: number) => void;
|
||||
onTap?: (x: number, y: number) => void;
|
||||
onLongPress?: (x: number, y: number) => void;
|
||||
onDoubleTap?: (x: number, y: number) => void;
|
||||
}
|
||||
|
||||
export interface GestureConfig {
|
||||
swipeThreshold?: number;
|
||||
swipeVelocityThreshold?: number;
|
||||
longPressDelay?: number;
|
||||
doubleTapDelay?: number;
|
||||
pinchThreshold?: number;
|
||||
}
|
||||
|
||||
const defaultConfig: Required<GestureConfig> = {
|
||||
swipeThreshold: 50,
|
||||
swipeVelocityThreshold: 0.3,
|
||||
longPressDelay: 500,
|
||||
doubleTapDelay: 300,
|
||||
pinchThreshold: 0.1,
|
||||
};
|
||||
|
||||
export function createGestureHandler(
|
||||
element: HTMLElement,
|
||||
handlers: GestureHandlers,
|
||||
config: GestureConfig = {}
|
||||
): () => void {
|
||||
const cfg = { ...defaultConfig, ...config };
|
||||
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startTime = 0;
|
||||
let longPressTimer: NodeJS.Timeout | null = null;
|
||||
let lastTapTime = 0;
|
||||
let initialDistance = 0;
|
||||
let initialAngle = 0;
|
||||
|
||||
const getDistance = (touches: TouchList): number => {
|
||||
if (touches.length < 2) return 0;
|
||||
const dx = touches[1].clientX - touches[0].clientX;
|
||||
const dy = touches[1].clientY - touches[0].clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
};
|
||||
|
||||
const getAngle = (touches: TouchList): number => {
|
||||
if (touches.length < 2) return 0;
|
||||
const dx = touches[1].clientX - touches[0].clientX;
|
||||
const dy = touches[1].clientY - touches[0].clientY;
|
||||
return (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
startTime = Date.now();
|
||||
|
||||
// Long press detection
|
||||
if (handlers.onLongPress) {
|
||||
longPressTimer = setTimeout(() => {
|
||||
handlers.onLongPress!(startX, startY);
|
||||
// Vibrate on long press if available
|
||||
if ('vibrate' in navigator) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
}, cfg.longPressDelay);
|
||||
}
|
||||
|
||||
// Pinch/rotate initialization
|
||||
if (e.touches.length === 2) {
|
||||
initialDistance = getDistance(e.touches);
|
||||
initialAngle = getAngle(e.touches);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
// Cancel long press on move
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
|
||||
// Pinch detection
|
||||
if (e.touches.length === 2 && (handlers.onPinch || handlers.onRotate)) {
|
||||
const currentDistance = getDistance(e.touches);
|
||||
const currentAngle = getAngle(e.touches);
|
||||
|
||||
if (handlers.onPinch && initialDistance > 0) {
|
||||
const scale = currentDistance / initialDistance;
|
||||
if (Math.abs(scale - 1) > cfg.pinchThreshold) {
|
||||
handlers.onPinch(scale);
|
||||
}
|
||||
}
|
||||
|
||||
if (handlers.onRotate) {
|
||||
const rotation = currentAngle - initialAngle;
|
||||
handlers.onRotate(rotation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: TouchEvent) => {
|
||||
// Clear long press timer
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
|
||||
const touch = e.changedTouches[0];
|
||||
const endX = touch.clientX;
|
||||
const endY = touch.clientY;
|
||||
const endTime = Date.now();
|
||||
|
||||
const deltaX = endX - startX;
|
||||
const deltaY = endY - startY;
|
||||
const deltaTime = endTime - startTime;
|
||||
|
||||
// Swipe detection
|
||||
if (handlers.onSwipe) {
|
||||
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
|
||||
|
||||
if (velocity > cfg.swipeVelocityThreshold) {
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
if (Math.abs(deltaX) > cfg.swipeThreshold) {
|
||||
handlers.onSwipe(deltaX > 0 ? 'right' : 'left', velocity);
|
||||
}
|
||||
} else {
|
||||
if (Math.abs(deltaY) > cfg.swipeThreshold) {
|
||||
handlers.onSwipe(deltaY > 0 ? 'down' : 'up', velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tap / Double tap detection
|
||||
if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && deltaTime < 300) {
|
||||
const now = Date.now();
|
||||
|
||||
if (handlers.onDoubleTap && now - lastTapTime < cfg.doubleTapDelay) {
|
||||
handlers.onDoubleTap(endX, endY);
|
||||
lastTapTime = 0;
|
||||
} else if (handlers.onTap) {
|
||||
lastTapTime = now;
|
||||
// Delay tap to wait for potential double tap
|
||||
if (handlers.onDoubleTap) {
|
||||
setTimeout(() => {
|
||||
if (lastTapTime === now) {
|
||||
handlers.onTap!(endX, endY);
|
||||
}
|
||||
}, cfg.doubleTapDelay);
|
||||
} else {
|
||||
handlers.onTap(endX, endY);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
longPressTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
element.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
element.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
element.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
element.addEventListener('touchcancel', handleTouchCancel, { passive: true });
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', handleTouchStart);
|
||||
element.removeEventListener('touchmove', handleTouchMove);
|
||||
element.removeEventListener('touchend', handleTouchEnd);
|
||||
element.removeEventListener('touchcancel', handleTouchCancel);
|
||||
|
||||
if (longPressTimer) {
|
||||
clearTimeout(longPressTimer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// React hook for gestures
|
||||
export function useGestures(
|
||||
ref: React.RefObject<HTMLElement>,
|
||||
handlers: GestureHandlers,
|
||||
config?: GestureConfig
|
||||
): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handlersRef = { current: handlers };
|
||||
handlersRef.current = handlers;
|
||||
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
// Note: In actual React usage, this should be inside a useEffect
|
||||
createGestureHandler(element, handlersRef.current, config);
|
||||
}
|
||||
|
||||
// Haptic feedback utility
|
||||
export function triggerHaptic(type: 'light' | 'medium' | 'heavy' = 'light'): void {
|
||||
if (!('vibrate' in navigator)) return;
|
||||
|
||||
const patterns: Record<typeof type, number | number[]> = {
|
||||
light: 10,
|
||||
medium: 25,
|
||||
heavy: [50, 50, 50],
|
||||
};
|
||||
|
||||
navigator.vibrate(patterns[type]);
|
||||
}
|
||||
|
||||
// Prevent pull-to-refresh on specific elements
|
||||
export function preventPullToRefresh(element: HTMLElement): () => void {
|
||||
let startY = 0;
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
startY = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
const currentY = e.touches[0].clientY;
|
||||
const scrollTop = element.scrollTop;
|
||||
|
||||
// Prevent if at top and pulling down
|
||||
if (scrollTop === 0 && currentY > startY) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
element.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', handleTouchStart);
|
||||
element.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export * from './camera';
|
||||
export * from './offline';
|
||||
export * from './gestures';
|
||||
export * from './pwa';
|
||||
|
|
@ -1,275 +0,0 @@
|
|||
/**
|
||||
* Offline Support Utilities
|
||||
* Provides IndexedDB storage and background sync for offline functionality
|
||||
*/
|
||||
|
||||
import { openDB, DBSchema, IDBPDatabase } from 'idb';
|
||||
|
||||
// Database schema
|
||||
interface LocalGreenChainDB extends DBSchema {
|
||||
'pending-plants': {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
data: any;
|
||||
createdAt: string;
|
||||
attempts: number;
|
||||
};
|
||||
indexes: { 'by-created': string };
|
||||
};
|
||||
'pending-transport': {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
data: any;
|
||||
createdAt: string;
|
||||
attempts: number;
|
||||
};
|
||||
indexes: { 'by-created': string };
|
||||
};
|
||||
'cached-plants': {
|
||||
key: string;
|
||||
value: {
|
||||
id: string;
|
||||
data: any;
|
||||
cachedAt: string;
|
||||
};
|
||||
indexes: { 'by-cached': string };
|
||||
};
|
||||
'user-preferences': {
|
||||
key: string;
|
||||
value: any;
|
||||
};
|
||||
}
|
||||
|
||||
const DB_NAME = 'localgreenchain-offline';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<LocalGreenChainDB>> | null = null;
|
||||
|
||||
async function getDB(): Promise<IDBPDatabase<LocalGreenChainDB>> {
|
||||
if (!dbPromise) {
|
||||
dbPromise = openDB<LocalGreenChainDB>(DB_NAME, DB_VERSION, {
|
||||
upgrade(db) {
|
||||
// Pending plants store
|
||||
if (!db.objectStoreNames.contains('pending-plants')) {
|
||||
const plantStore = db.createObjectStore('pending-plants', { keyPath: 'id' });
|
||||
plantStore.createIndex('by-created', 'createdAt');
|
||||
}
|
||||
|
||||
// Pending transport store
|
||||
if (!db.objectStoreNames.contains('pending-transport')) {
|
||||
const transportStore = db.createObjectStore('pending-transport', { keyPath: 'id' });
|
||||
transportStore.createIndex('by-created', 'createdAt');
|
||||
}
|
||||
|
||||
// Cached plants store
|
||||
if (!db.objectStoreNames.contains('cached-plants')) {
|
||||
const cacheStore = db.createObjectStore('cached-plants', { keyPath: 'id' });
|
||||
cacheStore.createIndex('by-cached', 'cachedAt');
|
||||
}
|
||||
|
||||
// User preferences store
|
||||
if (!db.objectStoreNames.contains('user-preferences')) {
|
||||
db.createObjectStore('user-preferences');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
// Network status
|
||||
export function isOnline(): boolean {
|
||||
return typeof navigator !== 'undefined' ? navigator.onLine : true;
|
||||
}
|
||||
|
||||
export function onNetworkChange(callback: (online: boolean) => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
const handleOnline = () => callback(true);
|
||||
const handleOffline = () => callback(false);
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}
|
||||
|
||||
// Pending operations
|
||||
export async function queuePlantRegistration(plantData: any): Promise<string> {
|
||||
const db = await getDB();
|
||||
const id = generateId();
|
||||
|
||||
await db.put('pending-plants', {
|
||||
id,
|
||||
data: plantData,
|
||||
createdAt: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
// Register for background sync if available
|
||||
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await (registration as any).sync.register('sync-plants');
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function queueTransportEvent(eventData: any): Promise<string> {
|
||||
const db = await getDB();
|
||||
const id = generateId();
|
||||
|
||||
await db.put('pending-transport', {
|
||||
id,
|
||||
data: eventData,
|
||||
createdAt: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
// Register for background sync if available
|
||||
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await (registration as any).sync.register('sync-transport');
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function getPendingPlants(): Promise<any[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('pending-plants');
|
||||
}
|
||||
|
||||
export async function getPendingTransport(): Promise<any[]> {
|
||||
const db = await getDB();
|
||||
return db.getAll('pending-transport');
|
||||
}
|
||||
|
||||
export async function removePendingPlant(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.delete('pending-plants', id);
|
||||
}
|
||||
|
||||
export async function removePendingTransport(id: string): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.delete('pending-transport', id);
|
||||
}
|
||||
|
||||
// Plant caching
|
||||
export async function cachePlant(plant: any): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('cached-plants', {
|
||||
id: plant.id,
|
||||
data: plant,
|
||||
cachedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCachedPlant(id: string): Promise<any | null> {
|
||||
const db = await getDB();
|
||||
const cached = await db.get('cached-plants', id);
|
||||
return cached?.data || null;
|
||||
}
|
||||
|
||||
export async function getCachedPlants(): Promise<any[]> {
|
||||
const db = await getDB();
|
||||
const all = await db.getAll('cached-plants');
|
||||
return all.map((item) => item.data);
|
||||
}
|
||||
|
||||
export async function clearCachedPlants(): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.clear('cached-plants');
|
||||
}
|
||||
|
||||
// User preferences
|
||||
export async function setPreference(key: string, value: any): Promise<void> {
|
||||
const db = await getDB();
|
||||
await db.put('user-preferences', value, key);
|
||||
}
|
||||
|
||||
export async function getPreference<T>(key: string): Promise<T | null> {
|
||||
const db = await getDB();
|
||||
return db.get('user-preferences', key) as Promise<T | null>;
|
||||
}
|
||||
|
||||
// Sync operations
|
||||
export async function syncPendingPlants(): Promise<{ success: number; failed: number }> {
|
||||
const pending = await getPendingPlants();
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const response = await fetch('/api/plants/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item.data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await removePendingPlant(item.id);
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
export async function syncPendingTransport(): Promise<{ success: number; failed: number }> {
|
||||
const pending = await getPendingTransport();
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const item of pending) {
|
||||
try {
|
||||
const response = await fetch('/api/transport/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item.data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await removePendingTransport(item.id);
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
export async function syncAll(): Promise<void> {
|
||||
if (!isOnline()) return;
|
||||
|
||||
await Promise.all([
|
||||
syncPendingPlants(),
|
||||
syncPendingTransport(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Utility
|
||||
function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Auto-sync when coming online
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
syncAll().catch(console.error);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
/**
|
||||
* PWA Utilities
|
||||
* Service worker registration, update handling, and install prompt management
|
||||
*/
|
||||
|
||||
export interface PWAStatus {
|
||||
isInstalled: boolean;
|
||||
isStandalone: boolean;
|
||||
canInstall: boolean;
|
||||
isOnline: boolean;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[];
|
||||
readonly userChoice: Promise<{
|
||||
outcome: 'accepted' | 'dismissed';
|
||||
platform: string;
|
||||
}>;
|
||||
prompt(): Promise<void>;
|
||||
}
|
||||
|
||||
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||
let swRegistration: ServiceWorkerRegistration | null = null;
|
||||
|
||||
// Check if running as installed PWA
|
||||
export function isInstalled(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
(window.navigator as any).standalone === true ||
|
||||
document.referrer.includes('android-app://')
|
||||
);
|
||||
}
|
||||
|
||||
// Check if running in standalone mode
|
||||
export function isStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia('(display-mode: standalone)').matches;
|
||||
}
|
||||
|
||||
// Check if app can be installed
|
||||
export function canInstall(): boolean {
|
||||
return deferredPrompt !== null;
|
||||
}
|
||||
|
||||
// Get current PWA status
|
||||
export function getPWAStatus(): PWAStatus {
|
||||
return {
|
||||
isInstalled: isInstalled(),
|
||||
isStandalone: isStandalone(),
|
||||
canInstall: canInstall(),
|
||||
isOnline: typeof navigator !== 'undefined' ? navigator.onLine : true,
|
||||
updateAvailable: false, // Updated by service worker
|
||||
};
|
||||
}
|
||||
|
||||
// Prompt user to install PWA
|
||||
export async function promptInstall(): Promise<boolean> {
|
||||
if (!deferredPrompt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
deferredPrompt = null;
|
||||
|
||||
return outcome === 'accepted';
|
||||
}
|
||||
|
||||
// Register service worker
|
||||
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/',
|
||||
});
|
||||
|
||||
swRegistration = registration;
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60 * 60 * 1000); // Check every hour
|
||||
|
||||
// Handle updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (!newWorker) return;
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New update available
|
||||
dispatchPWAEvent('updateavailable');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error('Service worker registration failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending update
|
||||
export async function applyUpdate(): Promise<void> {
|
||||
if (!swRegistration?.waiting) return;
|
||||
|
||||
// Tell service worker to skip waiting
|
||||
swRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
|
||||
// Reload page after new service worker takes over
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
// Unregister service worker
|
||||
export async function unregisterServiceWorker(): Promise<boolean> {
|
||||
if (!swRegistration) return false;
|
||||
|
||||
const success = await swRegistration.unregister();
|
||||
if (success) {
|
||||
swRegistration = null;
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
export async function clearCaches(): Promise<void> {
|
||||
if (typeof caches === 'undefined') return;
|
||||
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
|
||||
if (!swRegistration) {
|
||||
console.error('Service worker not registered');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = await swRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
export async function unsubscribeFromPush(): Promise<boolean> {
|
||||
if (!swRegistration) return false;
|
||||
|
||||
try {
|
||||
const subscription = await swRegistration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
return await subscription.unsubscribe();
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check push notification permission
|
||||
export function getPushPermission(): NotificationPermission {
|
||||
if (typeof Notification === 'undefined') return 'denied';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
// Request push notification permission
|
||||
export async function requestPushPermission(): Promise<NotificationPermission> {
|
||||
if (typeof Notification === 'undefined') return 'denied';
|
||||
return await Notification.requestPermission();
|
||||
}
|
||||
|
||||
// Initialize PWA
|
||||
export function initPWA(): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
|
||||
// Listen for install prompt
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e as BeforeInstallPromptEvent;
|
||||
dispatchPWAEvent('caninstall');
|
||||
};
|
||||
|
||||
// Listen for app installed
|
||||
const handleAppInstalled = () => {
|
||||
deferredPrompt = null;
|
||||
dispatchPWAEvent('installed');
|
||||
};
|
||||
|
||||
// Listen for online/offline
|
||||
const handleOnline = () => dispatchPWAEvent('online');
|
||||
const handleOffline = () => dispatchPWAEvent('offline');
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', handleAppInstalled);
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
// Register service worker
|
||||
registerServiceWorker();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
}
|
||||
|
||||
// PWA event dispatcher
|
||||
function dispatchPWAEvent(type: string, detail?: any): void {
|
||||
window.dispatchEvent(new CustomEvent(`pwa:${type}`, { detail }));
|
||||
}
|
||||
|
||||
// Utility to convert VAPID key
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// Share API wrapper
|
||||
export async function share(data: ShareData): Promise<boolean> {
|
||||
if (!navigator.share) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.share(data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Share failed:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Web Share API is supported
|
||||
export function canShare(data?: ShareData): boolean {
|
||||
if (!navigator.share) return false;
|
||||
if (!data) return true;
|
||||
return navigator.canShare?.(data) ?? true;
|
||||
}
|
||||
|
|
@ -1,358 +0,0 @@
|
|||
/**
|
||||
* Email Notification Channel
|
||||
* Handles sending email notifications via SMTP or SendGrid
|
||||
*/
|
||||
|
||||
import { EmailNotificationData, NotificationPayload, NotificationType } from '../types';
|
||||
|
||||
interface EmailConfig {
|
||||
provider: 'sendgrid' | 'nodemailer' | 'smtp';
|
||||
apiKey?: string;
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class EmailChannel {
|
||||
private config: EmailConfig;
|
||||
|
||||
constructor(config: EmailConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email notification
|
||||
*/
|
||||
async send(data: EmailNotificationData): Promise<void> {
|
||||
const emailData = {
|
||||
...data,
|
||||
from: data.from || this.config.from,
|
||||
replyTo: data.replyTo || this.config.replyTo
|
||||
};
|
||||
|
||||
switch (this.config.provider) {
|
||||
case 'sendgrid':
|
||||
await this.sendViaSendGrid(emailData);
|
||||
break;
|
||||
case 'smtp':
|
||||
case 'nodemailer':
|
||||
await this.sendViaSMTP(emailData);
|
||||
break;
|
||||
default:
|
||||
// Development mode - log email
|
||||
console.log('[EmailChannel] Development mode - Email would be sent:', {
|
||||
to: emailData.to,
|
||||
subject: emailData.subject,
|
||||
preview: emailData.text?.substring(0, 100)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send via SendGrid API
|
||||
*/
|
||||
private async sendViaSendGrid(data: EmailNotificationData): Promise<void> {
|
||||
if (!this.config.apiKey) {
|
||||
throw new Error('SendGrid API key not configured');
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
personalizations: [{ to: [{ email: data.to }] }],
|
||||
from: { email: data.from },
|
||||
reply_to: data.replyTo ? { email: data.replyTo } : undefined,
|
||||
subject: data.subject,
|
||||
content: [
|
||||
{ type: 'text/plain', value: data.text || data.html.replace(/<[^>]*>/g, '') },
|
||||
{ type: 'text/html', value: data.html }
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`SendGrid error: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send via SMTP (using nodemailer-like approach)
|
||||
*/
|
||||
private async sendViaSMTP(data: EmailNotificationData): Promise<void> {
|
||||
// In production, this would use nodemailer
|
||||
// For now, we'll simulate the SMTP send
|
||||
if (!this.config.smtp?.host) {
|
||||
console.log('[EmailChannel] SMTP not configured - simulating send');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate SMTP connection and send
|
||||
console.log(`[EmailChannel] Sending email via SMTP to ${data.to}`);
|
||||
|
||||
// In production implementation:
|
||||
// const nodemailer = require('nodemailer');
|
||||
// const transporter = nodemailer.createTransport({
|
||||
// host: this.config.smtp.host,
|
||||
// port: this.config.smtp.port,
|
||||
// secure: this.config.smtp.secure,
|
||||
// auth: {
|
||||
// user: this.config.smtp.user,
|
||||
// pass: this.config.smtp.pass
|
||||
// }
|
||||
// });
|
||||
// await transporter.sendMail(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render email template based on notification type
|
||||
*/
|
||||
async renderTemplate(payload: NotificationPayload): Promise<string> {
|
||||
const templates = {
|
||||
welcome: this.getWelcomeTemplate,
|
||||
plant_registered: this.getPlantRegisteredTemplate,
|
||||
plant_reminder: this.getPlantReminderTemplate,
|
||||
transport_alert: this.getTransportAlertTemplate,
|
||||
farm_alert: this.getFarmAlertTemplate,
|
||||
harvest_ready: this.getHarvestReadyTemplate,
|
||||
demand_match: this.getDemandMatchTemplate,
|
||||
weekly_digest: this.getWeeklyDigestTemplate,
|
||||
system_alert: this.getSystemAlertTemplate
|
||||
};
|
||||
|
||||
const templateFn = templates[payload.type] || this.getDefaultTemplate;
|
||||
return templateFn.call(this, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base email layout
|
||||
*/
|
||||
private getBaseLayout(content: string, payload: NotificationPayload): string {
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${payload.title}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background-color: #f5f5f5; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.logo { font-size: 32px; margin-bottom: 10px; }
|
||||
.content { background: white; padding: 30px; border-radius: 0 0 8px 8px; }
|
||||
.button { display: inline-block; background: #22c55e; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; margin: 20px 0; }
|
||||
.button:hover { background: #16a34a; }
|
||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
.alert-info { background: #dbeafe; border-left: 4px solid #3b82f6; padding: 15px; margin: 15px 0; }
|
||||
.alert-warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 15px 0; }
|
||||
.alert-success { background: #d1fae5; border-left: 4px solid #22c55e; padding: 15px; margin: 15px 0; }
|
||||
.stats { display: flex; justify-content: space-around; margin: 20px 0; }
|
||||
.stat-item { text-align: center; padding: 15px; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: #22c55e; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">🌱</div>
|
||||
<h1>LocalGreenChain</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
${content}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>LocalGreenChain - Transparent Seed-to-Seed Tracking</p>
|
||||
<p>
|
||||
<a href="{{unsubscribe_url}}">Manage notification preferences</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private getWelcomeTemplate(payload: NotificationPayload): string {
|
||||
const content = `
|
||||
<h2>Welcome to LocalGreenChain! 🌿</h2>
|
||||
<p>Thank you for joining our community of sustainable growers and conscious consumers.</p>
|
||||
<p>With LocalGreenChain, you can:</p>
|
||||
<ul>
|
||||
<li>Track your plants from seed to seed</li>
|
||||
<li>Monitor transport and carbon footprint</li>
|
||||
<li>Connect with local growers and consumers</li>
|
||||
<li>Manage vertical farms with precision</li>
|
||||
</ul>
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Get Started</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getPlantRegisteredTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const content = `
|
||||
<h2>Plant Registered Successfully 🌱</h2>
|
||||
<p>Your plant has been registered on the blockchain.</p>
|
||||
<div class="alert-success">
|
||||
<strong>Plant ID:</strong> ${data.plantId || 'N/A'}<br>
|
||||
<strong>Species:</strong> ${data.species || 'N/A'}<br>
|
||||
<strong>Variety:</strong> ${data.variety || 'N/A'}
|
||||
</div>
|
||||
<p>You can now track this plant throughout its entire lifecycle.</p>
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Plant Details</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getPlantReminderTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const content = `
|
||||
<h2>Plant Care Reminder 🌿</h2>
|
||||
<div class="alert-info">
|
||||
<strong>${payload.title}</strong><br>
|
||||
${payload.message}
|
||||
</div>
|
||||
${data.plantName ? `<p><strong>Plant:</strong> ${data.plantName}</p>` : ''}
|
||||
${data.action ? `<p><strong>Recommended Action:</strong> ${data.action}</p>` : ''}
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Plant</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getTransportAlertTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const content = `
|
||||
<h2>Transport Update 🚚</h2>
|
||||
<div class="alert-info">
|
||||
${payload.message}
|
||||
</div>
|
||||
${data.distance ? `
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.distance} km</div>
|
||||
<div class="stat-label">Distance</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.carbonKg || '0'} kg</div>
|
||||
<div class="stat-label">Carbon Footprint</div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Journey</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getFarmAlertTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const alertClass = data.severity === 'warning' ? 'alert-warning' : 'alert-info';
|
||||
const content = `
|
||||
<h2>Farm Alert ${data.severity === 'warning' ? '⚠️' : 'ℹ️'}</h2>
|
||||
<div class="${alertClass}">
|
||||
<strong>${payload.title}</strong><br>
|
||||
${payload.message}
|
||||
</div>
|
||||
${data.zone ? `<p><strong>Zone:</strong> ${data.zone}</p>` : ''}
|
||||
${data.recommendation ? `<p><strong>Recommendation:</strong> ${data.recommendation}</p>` : ''}
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Farm Dashboard</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getHarvestReadyTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const content = `
|
||||
<h2>Harvest Ready! 🎉</h2>
|
||||
<div class="alert-success">
|
||||
<strong>Great news!</strong> Your crop is ready for harvest.
|
||||
</div>
|
||||
${data.batchId ? `<p><strong>Batch:</strong> ${data.batchId}</p>` : ''}
|
||||
${data.cropType ? `<p><strong>Crop:</strong> ${data.cropType}</p>` : ''}
|
||||
${data.estimatedYield ? `<p><strong>Estimated Yield:</strong> ${data.estimatedYield}</p>` : ''}
|
||||
<p>Log the harvest to update your blockchain records.</p>
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Log Harvest</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getDemandMatchTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const content = `
|
||||
<h2>Demand Match Found! 🤝</h2>
|
||||
<p>We've found a match between supply and demand.</p>
|
||||
<div class="alert-success">
|
||||
${payload.message}
|
||||
</div>
|
||||
${data.matchDetails ? `
|
||||
<p><strong>Crop:</strong> ${data.matchDetails.crop}</p>
|
||||
<p><strong>Quantity:</strong> ${data.matchDetails.quantity}</p>
|
||||
<p><strong>Region:</strong> ${data.matchDetails.region}</p>
|
||||
` : ''}
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Match Details</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getWeeklyDigestTemplate(payload: NotificationPayload): string {
|
||||
const data = payload.data || {};
|
||||
const content = `
|
||||
<h2>Your Weekly Summary 📊</h2>
|
||||
<p>Here's what happened this week on LocalGreenChain:</p>
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.plantsRegistered || 0}</div>
|
||||
<div class="stat-label">Plants Registered</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.carbonSaved || 0} kg</div>
|
||||
<div class="stat-label">Carbon Saved</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">${data.localMiles || 0}</div>
|
||||
<div class="stat-label">Local Food Miles</div>
|
||||
</div>
|
||||
</div>
|
||||
${data.highlights ? `
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
${data.highlights.map((h: string) => `<li>${h}</li>`).join('')}
|
||||
</ul>
|
||||
` : ''}
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Full Report</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getSystemAlertTemplate(payload: NotificationPayload): string {
|
||||
const content = `
|
||||
<h2>System Notification ⚙️</h2>
|
||||
<div class="alert-info">
|
||||
<strong>${payload.title}</strong><br>
|
||||
${payload.message}
|
||||
</div>
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">Learn More</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
|
||||
private getDefaultTemplate(payload: NotificationPayload): string {
|
||||
const content = `
|
||||
<h2>${payload.title}</h2>
|
||||
<p>${payload.message}</p>
|
||||
${payload.actionUrl ? `<a href="${payload.actionUrl}" class="button">View Details</a>` : ''}
|
||||
`;
|
||||
return this.getBaseLayout(content, payload);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
/**
|
||||
* In-App Notification Channel
|
||||
* Handles in-application notifications with persistence
|
||||
*/
|
||||
|
||||
import { InAppNotification, NotificationType } from '../types';
|
||||
|
||||
export class InAppChannel {
|
||||
private notifications: Map<string, InAppNotification[]> = new Map();
|
||||
private maxNotificationsPerUser = 100;
|
||||
|
||||
/**
|
||||
* Send an in-app notification
|
||||
*/
|
||||
async send(notification: InAppNotification): Promise<void> {
|
||||
const userNotifications = this.notifications.get(notification.userId) || [];
|
||||
|
||||
// Add new notification at the beginning
|
||||
userNotifications.unshift(notification);
|
||||
|
||||
// Trim to max
|
||||
if (userNotifications.length > this.maxNotificationsPerUser) {
|
||||
userNotifications.splice(this.maxNotificationsPerUser);
|
||||
}
|
||||
|
||||
this.notifications.set(notification.userId, userNotifications);
|
||||
console.log(`[InAppChannel] Notification added for user ${notification.userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications for a user
|
||||
*/
|
||||
getNotifications(userId: string, options?: {
|
||||
unreadOnly?: boolean;
|
||||
type?: NotificationType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): InAppNotification[] {
|
||||
let userNotifications = this.notifications.get(userId) || [];
|
||||
|
||||
// Filter by unread
|
||||
if (options?.unreadOnly) {
|
||||
userNotifications = userNotifications.filter(n => !n.read);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (options?.type) {
|
||||
userNotifications = userNotifications.filter(n => n.type === options.type);
|
||||
}
|
||||
|
||||
// Filter expired
|
||||
const now = new Date().toISOString();
|
||||
userNotifications = userNotifications.filter(n =>
|
||||
!n.expiresAt || n.expiresAt > now
|
||||
);
|
||||
|
||||
// Apply pagination
|
||||
const offset = options?.offset || 0;
|
||||
const limit = options?.limit || 50;
|
||||
|
||||
return userNotifications.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification by ID
|
||||
*/
|
||||
getNotification(notificationId: string): InAppNotification | undefined {
|
||||
for (const userNotifications of this.notifications.values()) {
|
||||
const notification = userNotifications.find(n => n.id === notificationId);
|
||||
if (notification) return notification;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
markAsRead(notificationId: string): boolean {
|
||||
for (const [userId, userNotifications] of this.notifications.entries()) {
|
||||
const notification = userNotifications.find(n => n.id === notificationId);
|
||||
if (notification) {
|
||||
notification.read = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
markAllAsRead(userId: string): number {
|
||||
const userNotifications = this.notifications.get(userId);
|
||||
if (!userNotifications) return 0;
|
||||
|
||||
let count = 0;
|
||||
userNotifications.forEach(n => {
|
||||
if (!n.read) {
|
||||
n.read = true;
|
||||
count++;
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a user
|
||||
*/
|
||||
getUnreadCount(userId: string): number {
|
||||
const userNotifications = this.notifications.get(userId) || [];
|
||||
return userNotifications.filter(n => !n.read).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
delete(notificationId: string): boolean {
|
||||
for (const [userId, userNotifications] of this.notifications.entries()) {
|
||||
const index = userNotifications.findIndex(n => n.id === notificationId);
|
||||
if (index !== -1) {
|
||||
userNotifications.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all notifications for a user
|
||||
*/
|
||||
deleteAll(userId: string): number {
|
||||
const userNotifications = this.notifications.get(userId);
|
||||
if (!userNotifications) return 0;
|
||||
|
||||
const count = userNotifications.length;
|
||||
this.notifications.delete(userId);
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired notifications
|
||||
*/
|
||||
cleanupExpired(): number {
|
||||
const now = new Date().toISOString();
|
||||
let removed = 0;
|
||||
|
||||
for (const [userId, userNotifications] of this.notifications.entries()) {
|
||||
const originalLength = userNotifications.length;
|
||||
const filtered = userNotifications.filter(n =>
|
||||
!n.expiresAt || n.expiresAt > now
|
||||
);
|
||||
removed += originalLength - filtered.length;
|
||||
this.notifications.set(userId, filtered);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for a user
|
||||
*/
|
||||
getStats(userId: string): {
|
||||
total: number;
|
||||
unread: number;
|
||||
byType: Record<NotificationType, number>;
|
||||
} {
|
||||
const userNotifications = this.notifications.get(userId) || [];
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
userNotifications.forEach(n => {
|
||||
byType[n.type] = (byType[n.type] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
total: userNotifications.length,
|
||||
unread: userNotifications.filter(n => !n.read).length,
|
||||
byType: byType as Record<NotificationType, number>
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group notifications by date
|
||||
*/
|
||||
getGroupedByDate(userId: string): {
|
||||
today: InAppNotification[];
|
||||
yesterday: InAppNotification[];
|
||||
thisWeek: InAppNotification[];
|
||||
older: InAppNotification[];
|
||||
} {
|
||||
const userNotifications = this.notifications.get(userId) || [];
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const result = {
|
||||
today: [] as InAppNotification[],
|
||||
yesterday: [] as InAppNotification[],
|
||||
thisWeek: [] as InAppNotification[],
|
||||
older: [] as InAppNotification[]
|
||||
};
|
||||
|
||||
userNotifications.forEach(n => {
|
||||
const createdAt = new Date(n.createdAt);
|
||||
if (createdAt >= today) {
|
||||
result.today.push(n);
|
||||
} else if (createdAt >= yesterday) {
|
||||
result.yesterday.push(n);
|
||||
} else if (createdAt >= weekAgo) {
|
||||
result.thisWeek.push(n);
|
||||
} else {
|
||||
result.older.push(n);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
/**
|
||||
* Push Notification Channel
|
||||
* Handles Web Push notifications using VAPID
|
||||
*/
|
||||
|
||||
import { PushNotificationData, PushSubscription } from '../types';
|
||||
|
||||
interface PushConfig {
|
||||
vapidPublicKey: string;
|
||||
vapidPrivateKey: string;
|
||||
vapidSubject: string;
|
||||
}
|
||||
|
||||
export class PushChannel {
|
||||
private config: PushConfig;
|
||||
private subscriptions: Map<string, PushSubscription[]> = new Map();
|
||||
|
||||
constructor(config: PushConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a push notification
|
||||
*/
|
||||
async send(data: PushNotificationData): Promise<void> {
|
||||
if (!this.config.vapidPublicKey || !this.config.vapidPrivateKey) {
|
||||
console.log('[PushChannel] VAPID keys not configured - simulating push');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: data.title,
|
||||
body: data.body,
|
||||
icon: data.icon || '/icons/icon-192x192.png',
|
||||
badge: data.badge || '/icons/badge-72x72.png',
|
||||
data: data.data,
|
||||
actions: data.actions
|
||||
});
|
||||
|
||||
// In production, this would use web-push library:
|
||||
// const webpush = require('web-push');
|
||||
// webpush.setVapidDetails(
|
||||
// this.config.vapidSubject,
|
||||
// this.config.vapidPublicKey,
|
||||
// this.config.vapidPrivateKey
|
||||
// );
|
||||
// await webpush.sendNotification(subscription, payload);
|
||||
|
||||
console.log(`[PushChannel] Push notification sent: ${data.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a user to push notifications
|
||||
*/
|
||||
subscribe(userId: string, subscription: Omit<PushSubscription, 'userId' | 'createdAt'>): PushSubscription {
|
||||
const fullSubscription: PushSubscription = {
|
||||
...subscription,
|
||||
userId,
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
const userSubs = this.subscriptions.get(userId) || [];
|
||||
|
||||
// Check if subscription already exists
|
||||
const existing = userSubs.find(s => s.endpoint === subscription.endpoint);
|
||||
if (existing) {
|
||||
existing.lastUsedAt = new Date().toISOString();
|
||||
return existing;
|
||||
}
|
||||
|
||||
userSubs.push(fullSubscription);
|
||||
this.subscriptions.set(userId, userSubs);
|
||||
|
||||
console.log(`[PushChannel] User ${userId} subscribed to push notifications`);
|
||||
return fullSubscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from push notifications
|
||||
*/
|
||||
unsubscribe(userId: string, endpoint?: string): boolean {
|
||||
if (!endpoint) {
|
||||
// Remove all subscriptions for user
|
||||
this.subscriptions.delete(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const userSubs = this.subscriptions.get(userId);
|
||||
if (!userSubs) return false;
|
||||
|
||||
const index = userSubs.findIndex(s => s.endpoint === endpoint);
|
||||
if (index === -1) return false;
|
||||
|
||||
userSubs.splice(index, 1);
|
||||
if (userSubs.length === 0) {
|
||||
this.subscriptions.delete(userId);
|
||||
} else {
|
||||
this.subscriptions.set(userId, userSubs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get subscriptions for a user
|
||||
*/
|
||||
getSubscriptions(userId: string): PushSubscription[] {
|
||||
return this.subscriptions.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has push subscriptions
|
||||
*/
|
||||
hasSubscription(userId: string): boolean {
|
||||
const subs = this.subscriptions.get(userId);
|
||||
return subs !== undefined && subs.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to all user's subscriptions
|
||||
*/
|
||||
async sendToUser(userId: string, data: Omit<PushNotificationData, 'token'>): Promise<void> {
|
||||
const subscriptions = this.getSubscriptions(userId);
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
await this.send({
|
||||
...data,
|
||||
token: sub.endpoint
|
||||
});
|
||||
sub.lastUsedAt = new Date().toISOString();
|
||||
} catch (error: any) {
|
||||
console.error(`[PushChannel] Failed to send to ${sub.endpoint}:`, error.message);
|
||||
// Remove invalid subscriptions
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
this.unsubscribe(userId, sub.endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get VAPID public key for client
|
||||
*/
|
||||
getPublicKey(): string {
|
||||
return this.config.vapidPublicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate VAPID keys (utility method)
|
||||
*/
|
||||
static generateVapidKeys(): { publicKey: string; privateKey: string } {
|
||||
// In production, use web-push library:
|
||||
// const webpush = require('web-push');
|
||||
// return webpush.generateVAPIDKeys();
|
||||
|
||||
// For development, return placeholder keys
|
||||
return {
|
||||
publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
|
||||
privateKey: 'UUxI4O8-FbRouADVXc-hK3ltRAc8_DIoISjp22LG0S0'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
/**
|
||||
* LocalGreenChain Notification System
|
||||
* Multi-channel notifications with email, push, and in-app support
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export { NotificationService, getNotificationService } from './service';
|
||||
export { NotificationScheduler, getNotificationScheduler } from './scheduler';
|
||||
export { EmailChannel } from './channels/email';
|
||||
export { PushChannel } from './channels/push';
|
||||
export { InAppChannel } from './channels/inApp';
|
||||
|
||||
// Convenience functions
|
||||
import { getNotificationService } from './service';
|
||||
import { getNotificationScheduler } from './scheduler';
|
||||
import { NotificationPayload, NotificationChannel, NotificationPriority, NotificationRecipient } from './types';
|
||||
|
||||
/**
|
||||
* Send a notification (convenience function)
|
||||
*/
|
||||
export async function sendNotification(
|
||||
recipient: NotificationRecipient,
|
||||
payload: NotificationPayload,
|
||||
options?: {
|
||||
channels?: NotificationChannel[];
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
) {
|
||||
return getNotificationService().send(recipient, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a welcome notification
|
||||
*/
|
||||
export async function sendWelcomeNotification(userId: string, email: string, name?: string) {
|
||||
return sendNotification(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'welcome',
|
||||
title: `Welcome to LocalGreenChain${name ? `, ${name}` : ''}!`,
|
||||
message: 'Thank you for joining our community. Start tracking your plants today!',
|
||||
actionUrl: '/dashboard'
|
||||
},
|
||||
{ channels: ['email', 'inApp'] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a plant registered notification
|
||||
*/
|
||||
export async function sendPlantRegisteredNotification(
|
||||
userId: string,
|
||||
email: string,
|
||||
plantId: string,
|
||||
species: string,
|
||||
variety?: string
|
||||
) {
|
||||
return sendNotification(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'plant_registered',
|
||||
title: 'Plant Registered Successfully',
|
||||
message: `Your ${species}${variety ? ` (${variety})` : ''} has been registered on the blockchain.`,
|
||||
data: { plantId, species, variety },
|
||||
actionUrl: `/plants/${plantId}`
|
||||
},
|
||||
{ channels: ['inApp', 'email'] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a transport alert
|
||||
*/
|
||||
export async function sendTransportAlert(
|
||||
userId: string,
|
||||
email: string,
|
||||
plantId: string,
|
||||
eventType: string,
|
||||
distance?: number,
|
||||
carbonKg?: number
|
||||
) {
|
||||
return sendNotification(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'transport_alert',
|
||||
title: 'Transport Event Recorded',
|
||||
message: `A ${eventType} event has been logged for your plant.`,
|
||||
data: { plantId, eventType, distance, carbonKg },
|
||||
actionUrl: `/transport/journey/${plantId}`
|
||||
},
|
||||
{ channels: ['inApp'] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a farm alert
|
||||
*/
|
||||
export async function sendFarmAlert(
|
||||
userId: string,
|
||||
email: string,
|
||||
farmId: string,
|
||||
zone: string,
|
||||
severity: 'info' | 'warning' | 'critical',
|
||||
message: string,
|
||||
recommendation?: string
|
||||
) {
|
||||
return sendNotification(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'farm_alert',
|
||||
title: `Farm Alert: ${zone}`,
|
||||
message,
|
||||
data: { farmId, zone, severity, recommendation },
|
||||
actionUrl: `/vertical-farm/${farmId}`
|
||||
},
|
||||
{
|
||||
channels: severity === 'critical' ? ['inApp', 'email', 'push'] : ['inApp'],
|
||||
priority: severity === 'critical' ? 'urgent' : 'medium'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a demand match notification
|
||||
*/
|
||||
export async function sendDemandMatchNotification(
|
||||
userId: string,
|
||||
email: string,
|
||||
matchDetails: {
|
||||
crop: string;
|
||||
quantity: number;
|
||||
region: string;
|
||||
matchId: string;
|
||||
}
|
||||
) {
|
||||
return sendNotification(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'demand_match',
|
||||
title: 'New Demand Match Found!',
|
||||
message: `A consumer is looking for ${matchDetails.quantity} units of ${matchDetails.crop} in ${matchDetails.region}.`,
|
||||
data: { matchDetails },
|
||||
actionUrl: `/marketplace/match/${matchDetails.matchId}`
|
||||
},
|
||||
{ channels: ['inApp', 'email'], priority: 'high' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the notification scheduler
|
||||
*/
|
||||
export function startNotificationScheduler(intervalMs?: number) {
|
||||
getNotificationScheduler().start(intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the notification scheduler
|
||||
*/
|
||||
export function stopNotificationScheduler() {
|
||||
getNotificationScheduler().stop();
|
||||
}
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
/**
|
||||
* Notification Scheduler
|
||||
* Handles scheduled and recurring notifications
|
||||
*/
|
||||
|
||||
import { getNotificationService } from './service';
|
||||
import {
|
||||
ScheduledNotification,
|
||||
NotificationRecipient,
|
||||
NotificationPayload,
|
||||
NotificationChannel,
|
||||
NotificationPriority
|
||||
} from './types';
|
||||
|
||||
export class NotificationScheduler {
|
||||
private static instance: NotificationScheduler;
|
||||
private scheduledNotifications: Map<string, ScheduledNotification> = new Map();
|
||||
private checkInterval: NodeJS.Timeout | null = null;
|
||||
private isRunning = false;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): NotificationScheduler {
|
||||
if (!NotificationScheduler.instance) {
|
||||
NotificationScheduler.instance = new NotificationScheduler();
|
||||
}
|
||||
return NotificationScheduler.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the scheduler
|
||||
*/
|
||||
start(intervalMs: number = 60000): void {
|
||||
if (this.isRunning) return;
|
||||
|
||||
this.isRunning = true;
|
||||
console.log('[NotificationScheduler] Started');
|
||||
|
||||
// Check immediately
|
||||
this.processScheduledNotifications();
|
||||
|
||||
// Set up interval
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.processScheduledNotifications();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the scheduler
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
console.log('[NotificationScheduler] Stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a notification
|
||||
*/
|
||||
schedule(
|
||||
recipient: NotificationRecipient,
|
||||
payload: NotificationPayload,
|
||||
scheduledFor: Date | string,
|
||||
options?: {
|
||||
channels?: NotificationChannel[];
|
||||
priority?: NotificationPriority;
|
||||
recurring?: {
|
||||
pattern: 'daily' | 'weekly' | 'monthly';
|
||||
endDate?: string;
|
||||
};
|
||||
}
|
||||
): ScheduledNotification {
|
||||
const id = `sched-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const scheduled: ScheduledNotification = {
|
||||
id,
|
||||
notification: {
|
||||
recipientId: recipient.userId,
|
||||
payload,
|
||||
channels: options?.channels || ['inApp', 'email'],
|
||||
priority: options?.priority || 'medium',
|
||||
retryCount: 0
|
||||
},
|
||||
scheduledFor: typeof scheduledFor === 'string' ? scheduledFor : scheduledFor.toISOString(),
|
||||
recurring: options?.recurring,
|
||||
status: 'scheduled'
|
||||
};
|
||||
|
||||
this.scheduledNotifications.set(id, scheduled);
|
||||
console.log(`[NotificationScheduler] Scheduled notification ${id} for ${scheduled.scheduledFor}`);
|
||||
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a scheduled notification
|
||||
*/
|
||||
cancel(id: string): boolean {
|
||||
const scheduled = this.scheduledNotifications.get(id);
|
||||
if (scheduled && scheduled.status === 'scheduled') {
|
||||
scheduled.status = 'cancelled';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduled notification
|
||||
*/
|
||||
getScheduled(id: string): ScheduledNotification | undefined {
|
||||
return this.scheduledNotifications.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all scheduled notifications for a user
|
||||
*/
|
||||
getScheduledForUser(userId: string): ScheduledNotification[] {
|
||||
const result: ScheduledNotification[] = [];
|
||||
this.scheduledNotifications.forEach(scheduled => {
|
||||
if (scheduled.notification.recipientId === userId && scheduled.status === 'scheduled') {
|
||||
result.push(scheduled);
|
||||
}
|
||||
});
|
||||
return result.sort((a, b) =>
|
||||
new Date(a.scheduledFor).getTime() - new Date(b.scheduledFor).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a plant care reminder
|
||||
*/
|
||||
schedulePlantReminder(
|
||||
userId: string,
|
||||
email: string,
|
||||
plantId: string,
|
||||
plantName: string,
|
||||
reminderType: 'water' | 'fertilize' | 'prune' | 'harvest',
|
||||
scheduledFor: Date
|
||||
): ScheduledNotification {
|
||||
const reminderMessages = {
|
||||
water: `Time to water your ${plantName}!`,
|
||||
fertilize: `Your ${plantName} needs fertilizing.`,
|
||||
prune: `Consider pruning your ${plantName} for better growth.`,
|
||||
harvest: `Your ${plantName} may be ready for harvest!`
|
||||
};
|
||||
|
||||
return this.schedule(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'plant_reminder',
|
||||
title: `Plant Care Reminder: ${plantName}`,
|
||||
message: reminderMessages[reminderType],
|
||||
data: { plantId, plantName, reminderType },
|
||||
actionUrl: `/plants/${plantId}`
|
||||
},
|
||||
scheduledFor,
|
||||
{ channels: ['inApp', 'email', 'push'] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule weekly digest
|
||||
*/
|
||||
scheduleWeeklyDigest(userId: string, email: string): ScheduledNotification {
|
||||
// Schedule for next Monday at 9 AM
|
||||
const now = new Date();
|
||||
const nextMonday = new Date(now);
|
||||
nextMonday.setDate(now.getDate() + ((7 - now.getDay() + 1) % 7 || 7));
|
||||
nextMonday.setHours(9, 0, 0, 0);
|
||||
|
||||
return this.schedule(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'weekly_digest',
|
||||
title: 'Your Weekly LocalGreenChain Summary',
|
||||
message: 'Check out what happened this week!',
|
||||
actionUrl: '/dashboard'
|
||||
},
|
||||
nextMonday,
|
||||
{
|
||||
channels: ['email'],
|
||||
recurring: { pattern: 'weekly' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule harvest alert
|
||||
*/
|
||||
scheduleHarvestAlert(
|
||||
userId: string,
|
||||
email: string,
|
||||
batchId: string,
|
||||
cropType: string,
|
||||
estimatedHarvestDate: Date
|
||||
): ScheduledNotification {
|
||||
// Schedule alert 1 day before harvest
|
||||
const alertDate = new Date(estimatedHarvestDate);
|
||||
alertDate.setDate(alertDate.getDate() - 1);
|
||||
|
||||
return this.schedule(
|
||||
{ userId, email },
|
||||
{
|
||||
type: 'harvest_ready',
|
||||
title: `Harvest Coming Soon: ${cropType}`,
|
||||
message: `Your ${cropType} batch will be ready for harvest tomorrow!`,
|
||||
data: { batchId, cropType, estimatedHarvestDate: estimatedHarvestDate.toISOString() },
|
||||
actionUrl: `/vertical-farm/batch/${batchId}`
|
||||
},
|
||||
alertDate,
|
||||
{ channels: ['inApp', 'email', 'push'], priority: 'high' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process due notifications
|
||||
*/
|
||||
private async processScheduledNotifications(): Promise<void> {
|
||||
const now = new Date();
|
||||
const notificationService = getNotificationService();
|
||||
|
||||
for (const [id, scheduled] of this.scheduledNotifications.entries()) {
|
||||
if (scheduled.status !== 'scheduled') continue;
|
||||
|
||||
const scheduledTime = new Date(scheduled.scheduledFor);
|
||||
if (scheduledTime <= now) {
|
||||
try {
|
||||
// Send the notification
|
||||
await notificationService.send(
|
||||
{ userId: scheduled.notification.recipientId },
|
||||
scheduled.notification.payload,
|
||||
{
|
||||
channels: scheduled.notification.channels,
|
||||
priority: scheduled.notification.priority
|
||||
}
|
||||
);
|
||||
|
||||
// Handle recurring
|
||||
if (scheduled.recurring) {
|
||||
const endDate = scheduled.recurring.endDate
|
||||
? new Date(scheduled.recurring.endDate)
|
||||
: null;
|
||||
|
||||
if (!endDate || scheduledTime < endDate) {
|
||||
// Schedule next occurrence
|
||||
const nextDate = this.getNextOccurrence(scheduledTime, scheduled.recurring.pattern);
|
||||
scheduled.scheduledFor = nextDate.toISOString();
|
||||
console.log(`[NotificationScheduler] Rescheduled ${id} for ${scheduled.scheduledFor}`);
|
||||
} else {
|
||||
scheduled.status = 'sent';
|
||||
}
|
||||
} else {
|
||||
scheduled.status = 'sent';
|
||||
}
|
||||
|
||||
console.log(`[NotificationScheduler] Sent scheduled notification ${id}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[NotificationScheduler] Failed to send ${id}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate next occurrence date
|
||||
*/
|
||||
private getNextOccurrence(current: Date, pattern: 'daily' | 'weekly' | 'monthly'): Date {
|
||||
const next = new Date(current);
|
||||
|
||||
switch (pattern) {
|
||||
case 'daily':
|
||||
next.setDate(next.getDate() + 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
next.setDate(next.getDate() + 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler stats
|
||||
*/
|
||||
getStats(): {
|
||||
isRunning: boolean;
|
||||
total: number;
|
||||
scheduled: number;
|
||||
sent: number;
|
||||
cancelled: number;
|
||||
} {
|
||||
let scheduled = 0;
|
||||
let sent = 0;
|
||||
let cancelled = 0;
|
||||
|
||||
this.scheduledNotifications.forEach(n => {
|
||||
switch (n.status) {
|
||||
case 'scheduled': scheduled++; break;
|
||||
case 'sent': sent++; break;
|
||||
case 'cancelled': cancelled++; break;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
total: this.scheduledNotifications.size,
|
||||
scheduled,
|
||||
sent,
|
||||
cancelled
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old notifications
|
||||
*/
|
||||
cleanup(olderThanDays: number = 30): number {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - olderThanDays);
|
||||
|
||||
let removed = 0;
|
||||
for (const [id, scheduled] of this.scheduledNotifications.entries()) {
|
||||
if (scheduled.status !== 'scheduled') {
|
||||
const scheduledDate = new Date(scheduled.scheduledFor);
|
||||
if (scheduledDate < cutoff) {
|
||||
this.scheduledNotifications.delete(id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton getter
|
||||
export function getNotificationScheduler(): NotificationScheduler {
|
||||
return NotificationScheduler.getInstance();
|
||||
}
|
||||
|
|
@ -1,503 +0,0 @@
|
|||
/**
|
||||
* NotificationService - Core notification management service
|
||||
* Handles multi-channel notification dispatch and management
|
||||
*/
|
||||
|
||||
import {
|
||||
Notification,
|
||||
NotificationPayload,
|
||||
NotificationChannel,
|
||||
NotificationPriority,
|
||||
NotificationRecipient,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
UserNotificationPreferences,
|
||||
InAppNotification,
|
||||
NotificationStats,
|
||||
NotificationConfig
|
||||
} from './types';
|
||||
|
||||
import { EmailChannel } from './channels/email';
|
||||
import { PushChannel } from './channels/push';
|
||||
import { InAppChannel } from './channels/inApp';
|
||||
|
||||
export class NotificationService {
|
||||
private static instance: NotificationService;
|
||||
private notifications: Map<string, Notification> = new Map();
|
||||
private userPreferences: Map<string, UserNotificationPreferences> = new Map();
|
||||
private stats: NotificationStats;
|
||||
private config: NotificationConfig;
|
||||
|
||||
private emailChannel: EmailChannel;
|
||||
private pushChannel: PushChannel;
|
||||
private inAppChannel: InAppChannel;
|
||||
|
||||
private constructor() {
|
||||
this.config = this.loadConfig();
|
||||
this.stats = this.initializeStats();
|
||||
|
||||
this.emailChannel = new EmailChannel(this.config.email);
|
||||
this.pushChannel = new PushChannel(this.config.push);
|
||||
this.inAppChannel = new InAppChannel();
|
||||
}
|
||||
|
||||
static getInstance(): NotificationService {
|
||||
if (!NotificationService.instance) {
|
||||
NotificationService.instance = new NotificationService();
|
||||
}
|
||||
return NotificationService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a recipient
|
||||
*/
|
||||
async send(
|
||||
recipient: NotificationRecipient,
|
||||
payload: NotificationPayload,
|
||||
options?: {
|
||||
channels?: NotificationChannel[];
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
): Promise<Notification> {
|
||||
const notification = this.createNotification(recipient, payload, options);
|
||||
|
||||
// Store notification
|
||||
this.notifications.set(notification.id, notification);
|
||||
|
||||
// Check user preferences and quiet hours
|
||||
const preferences = this.getUserPreferences(recipient.userId);
|
||||
const channels = this.filterChannelsByPreferences(
|
||||
notification.channels,
|
||||
preferences,
|
||||
payload.type
|
||||
);
|
||||
|
||||
if (channels.length === 0) {
|
||||
notification.status = 'delivered';
|
||||
notification.deliveredAt = new Date().toISOString();
|
||||
return notification;
|
||||
}
|
||||
|
||||
// Check quiet hours
|
||||
if (this.isInQuietHours(preferences)) {
|
||||
// Queue for later if not urgent
|
||||
if (notification.priority !== 'urgent') {
|
||||
console.log(`[NotificationService] Notification ${notification.id} queued for after quiet hours`);
|
||||
return notification;
|
||||
}
|
||||
}
|
||||
|
||||
// Send through each channel
|
||||
await this.dispatchNotification(notification, recipient, channels);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to multiple recipients
|
||||
*/
|
||||
async sendBulk(
|
||||
recipients: NotificationRecipient[],
|
||||
payload: NotificationPayload,
|
||||
options?: {
|
||||
channels?: NotificationChannel[];
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
): Promise<Notification[]> {
|
||||
const notifications: Notification[] = [];
|
||||
|
||||
// Process in batches
|
||||
const batchSize = this.config.defaults.batchSize;
|
||||
for (let i = 0; i < recipients.length; i += batchSize) {
|
||||
const batch = recipients.slice(i, i + batchSize);
|
||||
const batchPromises = batch.map(recipient =>
|
||||
this.send(recipient, payload, options)
|
||||
);
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
|
||||
batchResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
notifications.push(result.value);
|
||||
} else {
|
||||
console.error(`[NotificationService] Failed to send to ${batch[index].userId}:`, result.reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications for a user
|
||||
*/
|
||||
getUserNotifications(userId: string, options?: {
|
||||
unreadOnly?: boolean;
|
||||
type?: NotificationType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): InAppNotification[] {
|
||||
return this.inAppChannel.getNotifications(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
markAsRead(notificationId: string, userId: string): boolean {
|
||||
const notification = this.notifications.get(notificationId);
|
||||
if (notification && notification.recipientId === userId) {
|
||||
notification.readAt = new Date().toISOString();
|
||||
notification.status = 'read';
|
||||
this.inAppChannel.markAsRead(notificationId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a user
|
||||
*/
|
||||
markAllAsRead(userId: string): number {
|
||||
return this.inAppChannel.markAllAsRead(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a user
|
||||
*/
|
||||
getUnreadCount(userId: string): number {
|
||||
return this.inAppChannel.getUnreadCount(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user notification preferences
|
||||
*/
|
||||
updatePreferences(userId: string, preferences: Partial<UserNotificationPreferences>): UserNotificationPreferences {
|
||||
const current = this.getUserPreferences(userId);
|
||||
const updated: UserNotificationPreferences = {
|
||||
...current,
|
||||
...preferences,
|
||||
userId
|
||||
};
|
||||
this.userPreferences.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user notification preferences
|
||||
*/
|
||||
getUserPreferences(userId: string): UserNotificationPreferences {
|
||||
return this.userPreferences.get(userId) || this.getDefaultPreferences(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification statistics
|
||||
*/
|
||||
getStats(): NotificationStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific notification
|
||||
*/
|
||||
getNotification(id: string): Notification | undefined {
|
||||
return this.notifications.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed notification
|
||||
*/
|
||||
async retry(notificationId: string): Promise<Notification | null> {
|
||||
const notification = this.notifications.get(notificationId);
|
||||
if (!notification || notification.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (notification.retryCount >= this.config.defaults.retryAttempts) {
|
||||
console.log(`[NotificationService] Max retries reached for ${notificationId}`);
|
||||
return notification;
|
||||
}
|
||||
|
||||
notification.retryCount++;
|
||||
notification.status = 'pending';
|
||||
notification.error = undefined;
|
||||
|
||||
// Re-dispatch
|
||||
const recipient: NotificationRecipient = {
|
||||
userId: notification.recipientId
|
||||
};
|
||||
|
||||
await this.dispatchNotification(notification, recipient, notification.channels);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete notification
|
||||
*/
|
||||
delete(notificationId: string, userId: string): boolean {
|
||||
const notification = this.notifications.get(notificationId);
|
||||
if (notification && notification.recipientId === userId) {
|
||||
this.notifications.delete(notificationId);
|
||||
this.inAppChannel.delete(notificationId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create notification object
|
||||
*/
|
||||
private createNotification(
|
||||
recipient: NotificationRecipient,
|
||||
payload: NotificationPayload,
|
||||
options?: {
|
||||
channels?: NotificationChannel[];
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
): Notification {
|
||||
const id = `notif-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
recipientId: recipient.userId,
|
||||
payload,
|
||||
channels: options?.channels || ['inApp', 'email'],
|
||||
priority: options?.priority || 'medium',
|
||||
status: 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
retryCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch notification through channels
|
||||
*/
|
||||
private async dispatchNotification(
|
||||
notification: Notification,
|
||||
recipient: NotificationRecipient,
|
||||
channels: NotificationChannel[]
|
||||
): Promise<void> {
|
||||
const results: { channel: NotificationChannel; success: boolean; error?: string }[] = [];
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
switch (channel) {
|
||||
case 'email':
|
||||
if (recipient.email) {
|
||||
await this.emailChannel.send({
|
||||
to: recipient.email,
|
||||
subject: notification.payload.title,
|
||||
html: await this.emailChannel.renderTemplate(notification.payload),
|
||||
text: notification.payload.message
|
||||
});
|
||||
results.push({ channel, success: true });
|
||||
this.stats.byChannel.email.sent++;
|
||||
this.stats.byChannel.email.delivered++;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'push':
|
||||
if (recipient.pushToken) {
|
||||
await this.pushChannel.send({
|
||||
token: recipient.pushToken,
|
||||
title: notification.payload.title,
|
||||
body: notification.payload.message,
|
||||
data: notification.payload.data
|
||||
});
|
||||
results.push({ channel, success: true });
|
||||
this.stats.byChannel.push.sent++;
|
||||
this.stats.byChannel.push.delivered++;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'inApp':
|
||||
await this.inAppChannel.send({
|
||||
id: notification.id,
|
||||
userId: recipient.userId,
|
||||
type: notification.payload.type,
|
||||
title: notification.payload.title,
|
||||
message: notification.payload.message,
|
||||
actionUrl: notification.payload.actionUrl,
|
||||
imageUrl: notification.payload.imageUrl,
|
||||
read: false,
|
||||
createdAt: notification.createdAt
|
||||
});
|
||||
results.push({ channel, success: true });
|
||||
this.stats.byChannel.inApp.sent++;
|
||||
this.stats.byChannel.inApp.delivered++;
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[NotificationService] Failed to send via ${channel}:`, error.message);
|
||||
results.push({ channel, success: false, error: error.message });
|
||||
this.stats.byChannel[channel].failed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update notification status
|
||||
const allSuccessful = results.every(r => r.success);
|
||||
const anySuccessful = results.some(r => r.success);
|
||||
|
||||
if (allSuccessful) {
|
||||
notification.status = 'delivered';
|
||||
notification.deliveredAt = new Date().toISOString();
|
||||
this.stats.totalDelivered++;
|
||||
} else if (anySuccessful) {
|
||||
notification.status = 'delivered';
|
||||
notification.deliveredAt = new Date().toISOString();
|
||||
notification.error = results.filter(r => !r.success).map(r => `${r.channel}: ${r.error}`).join('; ');
|
||||
this.stats.totalDelivered++;
|
||||
} else {
|
||||
notification.status = 'failed';
|
||||
notification.error = results.map(r => `${r.channel}: ${r.error}`).join('; ');
|
||||
this.stats.totalFailed++;
|
||||
}
|
||||
|
||||
notification.sentAt = new Date().toISOString();
|
||||
this.stats.totalSent++;
|
||||
this.stats.byType[notification.payload.type] = (this.stats.byType[notification.payload.type] || 0) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter channels by user preferences
|
||||
*/
|
||||
private filterChannelsByPreferences(
|
||||
channels: NotificationChannel[],
|
||||
preferences: UserNotificationPreferences,
|
||||
type: NotificationType
|
||||
): NotificationChannel[] {
|
||||
return channels.filter(channel => {
|
||||
// Check channel preference
|
||||
if (channel === 'email' && !preferences.email) return false;
|
||||
if (channel === 'push' && !preferences.push) return false;
|
||||
if (channel === 'inApp' && !preferences.inApp) return false;
|
||||
|
||||
// Check type-specific preference
|
||||
switch (type) {
|
||||
case 'plant_reminder':
|
||||
return preferences.plantReminders;
|
||||
case 'transport_alert':
|
||||
return preferences.transportAlerts;
|
||||
case 'farm_alert':
|
||||
return preferences.farmAlerts;
|
||||
case 'harvest_ready':
|
||||
return preferences.harvestAlerts;
|
||||
case 'demand_match':
|
||||
return preferences.demandMatches;
|
||||
case 'weekly_digest':
|
||||
return preferences.weeklyDigest;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current time is within quiet hours
|
||||
*/
|
||||
private isInQuietHours(preferences: UserNotificationPreferences): boolean {
|
||||
if (!preferences.quietHoursStart || !preferences.quietHoursEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const timezone = preferences.timezone || 'UTC';
|
||||
|
||||
try {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
|
||||
const currentTime = formatter.format(now);
|
||||
const [startHour, startMin] = preferences.quietHoursStart.split(':').map(Number);
|
||||
const [endHour, endMin] = preferences.quietHoursEnd.split(':').map(Number);
|
||||
const [currentHour, currentMin] = currentTime.split(':').map(Number);
|
||||
|
||||
const currentMinutes = currentHour * 60 + currentMin;
|
||||
const startMinutes = startHour * 60 + startMin;
|
||||
const endMinutes = endHour * 60 + endMin;
|
||||
|
||||
if (startMinutes <= endMinutes) {
|
||||
return currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
||||
} else {
|
||||
// Quiet hours span midnight
|
||||
return currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default preferences
|
||||
*/
|
||||
private getDefaultPreferences(userId: string): UserNotificationPreferences {
|
||||
return {
|
||||
userId,
|
||||
email: true,
|
||||
push: true,
|
||||
inApp: true,
|
||||
plantReminders: true,
|
||||
transportAlerts: true,
|
||||
farmAlerts: true,
|
||||
harvestAlerts: true,
|
||||
demandMatches: true,
|
||||
weeklyDigest: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize stats
|
||||
*/
|
||||
private initializeStats(): NotificationStats {
|
||||
return {
|
||||
totalSent: 0,
|
||||
totalDelivered: 0,
|
||||
totalFailed: 0,
|
||||
byChannel: {
|
||||
email: { sent: 0, delivered: 0, failed: 0 },
|
||||
push: { sent: 0, delivered: 0, failed: 0 },
|
||||
inApp: { sent: 0, delivered: 0, failed: 0 }
|
||||
},
|
||||
byType: {} as Record<NotificationType, number>
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration
|
||||
*/
|
||||
private loadConfig(): NotificationConfig {
|
||||
return {
|
||||
email: {
|
||||
provider: (process.env.EMAIL_PROVIDER as 'sendgrid' | 'nodemailer' | 'smtp') || 'nodemailer',
|
||||
apiKey: process.env.SENDGRID_API_KEY,
|
||||
from: process.env.EMAIL_FROM || 'noreply@localgreenchain.local',
|
||||
replyTo: process.env.EMAIL_REPLY_TO,
|
||||
smtp: {
|
||||
host: process.env.SMTP_HOST || 'localhost',
|
||||
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || ''
|
||||
}
|
||||
},
|
||||
push: {
|
||||
vapidPublicKey: process.env.VAPID_PUBLIC_KEY || '',
|
||||
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY || '',
|
||||
vapidSubject: process.env.VAPID_SUBJECT || 'mailto:admin@localgreenchain.local'
|
||||
},
|
||||
defaults: {
|
||||
retryAttempts: 3,
|
||||
retryDelayMs: 5000,
|
||||
batchSize: 50
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton getter
|
||||
export function getNotificationService(): NotificationService {
|
||||
return NotificationService.getInstance();
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
/**
|
||||
* Notification System Types
|
||||
* Multi-channel notification system for LocalGreenChain
|
||||
*/
|
||||
|
||||
export type NotificationChannel = 'email' | 'push' | 'inApp';
|
||||
export type NotificationPriority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
export type NotificationStatus = 'pending' | 'sent' | 'delivered' | 'failed' | 'read';
|
||||
export type NotificationType =
|
||||
| 'welcome'
|
||||
| 'plant_registered'
|
||||
| 'plant_reminder'
|
||||
| 'transport_alert'
|
||||
| 'farm_alert'
|
||||
| 'harvest_ready'
|
||||
| 'demand_match'
|
||||
| 'weekly_digest'
|
||||
| 'system_alert';
|
||||
|
||||
export interface NotificationRecipient {
|
||||
userId: string;
|
||||
email?: string;
|
||||
pushToken?: string;
|
||||
preferences?: UserNotificationPreferences;
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
data?: Record<string, any>;
|
||||
actionUrl?: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
recipientId: string;
|
||||
payload: NotificationPayload;
|
||||
channels: NotificationChannel[];
|
||||
priority: NotificationPriority;
|
||||
status: NotificationStatus;
|
||||
createdAt: string;
|
||||
sentAt?: string;
|
||||
deliveredAt?: string;
|
||||
readAt?: string;
|
||||
error?: string;
|
||||
retryCount: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UserNotificationPreferences {
|
||||
userId: string;
|
||||
email: boolean;
|
||||
push: boolean;
|
||||
inApp: boolean;
|
||||
plantReminders: boolean;
|
||||
transportAlerts: boolean;
|
||||
farmAlerts: boolean;
|
||||
harvestAlerts: boolean;
|
||||
demandMatches: boolean;
|
||||
weeklyDigest: boolean;
|
||||
quietHoursStart?: string; // HH:mm format
|
||||
quietHoursEnd?: string; // HH:mm format
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface EmailNotificationData {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
export interface PushNotificationData {
|
||||
token: string;
|
||||
title: string;
|
||||
body: string;
|
||||
icon?: string;
|
||||
badge?: string;
|
||||
data?: Record<string, any>;
|
||||
actions?: PushAction[];
|
||||
}
|
||||
|
||||
export interface PushAction {
|
||||
action: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PushSubscription {
|
||||
userId: string;
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
}
|
||||
|
||||
export interface InAppNotification {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
actionUrl?: string;
|
||||
imageUrl?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface NotificationQueue {
|
||||
notifications: Notification[];
|
||||
processing: boolean;
|
||||
lastProcessedAt?: string;
|
||||
}
|
||||
|
||||
export interface ScheduledNotification {
|
||||
id: string;
|
||||
notification: Omit<Notification, 'id' | 'createdAt' | 'status'>;
|
||||
scheduledFor: string;
|
||||
recurring?: {
|
||||
pattern: 'daily' | 'weekly' | 'monthly';
|
||||
endDate?: string;
|
||||
};
|
||||
status: 'scheduled' | 'sent' | 'cancelled';
|
||||
}
|
||||
|
||||
export interface NotificationStats {
|
||||
totalSent: number;
|
||||
totalDelivered: number;
|
||||
totalFailed: number;
|
||||
byChannel: {
|
||||
email: { sent: number; delivered: number; failed: number };
|
||||
push: { sent: number; delivered: number; failed: number };
|
||||
inApp: { sent: number; delivered: number; failed: number };
|
||||
};
|
||||
byType: Record<NotificationType, number>;
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
email: {
|
||||
provider: 'sendgrid' | 'nodemailer' | 'smtp';
|
||||
apiKey?: string;
|
||||
from: string;
|
||||
replyTo?: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
};
|
||||
push: {
|
||||
vapidPublicKey: string;
|
||||
vapidPrivateKey: string;
|
||||
vapidSubject: string;
|
||||
};
|
||||
defaults: {
|
||||
retryAttempts: number;
|
||||
retryDelayMs: number;
|
||||
batchSize: number;
|
||||
};
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ export interface FuzzyLocation {
|
|||
*/
|
||||
export function generateAnonymousId(): string {
|
||||
const randomBytes = crypto.randomBytes(32);
|
||||
return 'anon_' + crypto.createHash('sha256').update(new Uint8Array(randomBytes)).digest('hex').substring(0, 16);
|
||||
return 'anon_' + crypto.createHash('sha256').update(randomBytes).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,14 +111,14 @@ export function generateAnonymousPlantName(plantType: string, generation: number
|
|||
*/
|
||||
export function encryptData(data: string, key: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const keyHash = new Uint8Array(crypto.createHash('sha256').update(key).digest());
|
||||
const iv = new Uint8Array(crypto.randomBytes(16));
|
||||
const keyHash = crypto.createHash('sha256').update(key).digest();
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(algorithm, keyHash as any, iv as any);
|
||||
const cipher = crypto.createCipheriv(algorithm, keyHash, iv);
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
return Buffer.from(iv).toString('hex') + ':' + encrypted;
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -126,13 +126,13 @@ export function encryptData(data: string, key: string): string {
|
|||
*/
|
||||
export function decryptData(encryptedData: string, key: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const keyHash = new Uint8Array(crypto.createHash('sha256').update(key).digest());
|
||||
const keyHash = crypto.createHash('sha256').update(key).digest();
|
||||
|
||||
const parts = encryptedData.split(':');
|
||||
const iv = new Uint8Array(Buffer.from(parts[0], 'hex'));
|
||||
const iv = Buffer.from(parts[0], 'hex');
|
||||
const encrypted = parts[1];
|
||||
|
||||
const decipher = crypto.createDecipheriv(algorithm, keyHash as any, iv as any);
|
||||
const decipher = crypto.createDecipheriv(algorithm, keyHash, iv);
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,235 +0,0 @@
|
|||
/**
|
||||
* Socket.io Context Provider for LocalGreenChain
|
||||
*
|
||||
* Provides socket connection state to the entire application.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, useRef, ReactNode } from 'react';
|
||||
import { getSocketClient, RealtimeSocketClient } from './socketClient';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
TransparencyEvent,
|
||||
RoomType,
|
||||
TransparencyEventType,
|
||||
ConnectionMetrics,
|
||||
RealtimeNotification,
|
||||
} from './types';
|
||||
import { toFeedItem } from './events';
|
||||
|
||||
/**
|
||||
* Socket context value type
|
||||
*/
|
||||
interface SocketContextValue {
|
||||
// Connection state
|
||||
status: ConnectionStatus;
|
||||
isConnected: boolean;
|
||||
metrics: ConnectionMetrics;
|
||||
|
||||
// Events
|
||||
events: TransparencyEvent[];
|
||||
latestEvent: TransparencyEvent | null;
|
||||
|
||||
// Notifications
|
||||
notifications: RealtimeNotification[];
|
||||
unreadCount: number;
|
||||
|
||||
// Actions
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
joinRoom: (room: RoomType) => Promise<boolean>;
|
||||
leaveRoom: (room: RoomType) => Promise<boolean>;
|
||||
subscribeToTypes: (types: TransparencyEventType[]) => Promise<boolean>;
|
||||
clearEvents: () => void;
|
||||
markNotificationRead: (id: string) => void;
|
||||
dismissNotification: (id: string) => void;
|
||||
markAllRead: () => void;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Provider props
|
||||
*/
|
||||
interface SocketProviderProps {
|
||||
children: ReactNode;
|
||||
userId?: string;
|
||||
autoConnect?: boolean;
|
||||
maxEvents?: number;
|
||||
maxNotifications?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert event to notification
|
||||
*/
|
||||
function eventToNotification(event: TransparencyEvent): RealtimeNotification {
|
||||
const feedItem = toFeedItem(event);
|
||||
|
||||
let notificationType: RealtimeNotification['type'] = 'info';
|
||||
if (event.priority === 'CRITICAL') notificationType = 'error';
|
||||
else if (event.priority === 'HIGH') notificationType = 'warning';
|
||||
else if (event.type.includes('error')) notificationType = 'error';
|
||||
else if (event.type.includes('completed') || event.type.includes('verified')) notificationType = 'success';
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
type: notificationType,
|
||||
title: feedItem.formatted.title,
|
||||
message: feedItem.formatted.description,
|
||||
timestamp: feedItem.timestamp,
|
||||
eventType: event.type,
|
||||
data: event.data,
|
||||
read: false,
|
||||
dismissed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket Provider component
|
||||
*/
|
||||
export function SocketProvider({
|
||||
children,
|
||||
userId,
|
||||
autoConnect = true,
|
||||
maxEvents = 100,
|
||||
maxNotifications = 50,
|
||||
}: SocketProviderProps) {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [events, setEvents] = useState<TransparencyEvent[]>([]);
|
||||
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
|
||||
const [notifications, setNotifications] = useState<RealtimeNotification[]>([]);
|
||||
const [metrics, setMetrics] = useState<ConnectionMetrics>({
|
||||
status: 'disconnected',
|
||||
eventsReceived: 0,
|
||||
reconnectAttempts: 0,
|
||||
rooms: [],
|
||||
});
|
||||
|
||||
const clientRef = useRef<RealtimeSocketClient | null>(null);
|
||||
|
||||
// Initialize client
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const client = getSocketClient({ auth: { userId } });
|
||||
clientRef.current = client;
|
||||
|
||||
// Set up listeners
|
||||
const unsubStatus = client.onStatusChange((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setMetrics(client.getMetrics());
|
||||
});
|
||||
|
||||
const unsubEvent = client.onEvent((event) => {
|
||||
setLatestEvent(event);
|
||||
setEvents((prev) => [event, ...prev].slice(0, maxEvents));
|
||||
setMetrics(client.getMetrics());
|
||||
|
||||
// Create notification for important events
|
||||
if (event.priority === 'HIGH' || event.priority === 'CRITICAL') {
|
||||
const notification = eventToNotification(event);
|
||||
setNotifications((prev) => [notification, ...prev].slice(0, maxNotifications));
|
||||
}
|
||||
});
|
||||
|
||||
// Auto connect
|
||||
if (autoConnect) {
|
||||
client.connect();
|
||||
}
|
||||
|
||||
// Initial metrics
|
||||
setMetrics(client.getMetrics());
|
||||
|
||||
return () => {
|
||||
unsubStatus();
|
||||
unsubEvent();
|
||||
};
|
||||
}, [autoConnect, userId, maxEvents, maxNotifications]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
clientRef.current?.connect();
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
clientRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
const joinRoom = useCallback(async (room: RoomType) => {
|
||||
return clientRef.current?.joinRoom(room) ?? false;
|
||||
}, []);
|
||||
|
||||
const leaveRoom = useCallback(async (room: RoomType) => {
|
||||
return clientRef.current?.leaveRoom(room) ?? false;
|
||||
}, []);
|
||||
|
||||
const subscribeToTypes = useCallback(async (types: TransparencyEventType[]) => {
|
||||
return clientRef.current?.subscribeToTypes(types) ?? false;
|
||||
}, []);
|
||||
|
||||
const clearEvents = useCallback(() => {
|
||||
setEvents([]);
|
||||
setLatestEvent(null);
|
||||
}, []);
|
||||
|
||||
const markNotificationRead = useCallback((id: string) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const dismissNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, dismissed: true } : n))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const markAllRead = useCallback(() => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
}, []);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read && !n.dismissed).length;
|
||||
|
||||
const value: SocketContextValue = {
|
||||
status,
|
||||
isConnected: status === 'connected',
|
||||
metrics,
|
||||
events,
|
||||
latestEvent,
|
||||
notifications: notifications.filter((n) => !n.dismissed),
|
||||
unreadCount,
|
||||
connect,
|
||||
disconnect,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
subscribeToTypes,
|
||||
clearEvents,
|
||||
markNotificationRead,
|
||||
dismissNotification,
|
||||
markAllRead,
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use socket context
|
||||
*/
|
||||
export function useSocketContext(): SocketContextValue {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useSocketContext must be used within a SocketProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to optionally use socket context (returns null if not in provider)
|
||||
*/
|
||||
export function useOptionalSocketContext(): SocketContextValue | null {
|
||||
return useContext(SocketContext);
|
||||
}
|
||||
|
||||
export default SocketContext;
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
/**
|
||||
* Real-Time Event Definitions for LocalGreenChain
|
||||
*
|
||||
* Defines all real-time event types and utilities for formatting events.
|
||||
*/
|
||||
|
||||
import type { TransparencyEventType, TransparencyEvent, LiveFeedItem } from './types';
|
||||
|
||||
/**
|
||||
* Event categories for grouping and filtering
|
||||
*/
|
||||
export enum EventCategory {
|
||||
PLANT = 'plant',
|
||||
TRANSPORT = 'transport',
|
||||
DEMAND = 'demand',
|
||||
FARM = 'farm',
|
||||
AGENT = 'agent',
|
||||
BLOCKCHAIN = 'blockchain',
|
||||
SYSTEM = 'system',
|
||||
AUDIT = 'audit',
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time event types enum for client use
|
||||
*/
|
||||
export enum RealtimeEvent {
|
||||
// Plant events
|
||||
PLANT_REGISTERED = 'plant.registered',
|
||||
PLANT_CLONED = 'plant.cloned',
|
||||
PLANT_TRANSFERRED = 'plant.transferred',
|
||||
PLANT_UPDATED = 'plant.updated',
|
||||
|
||||
// Transport events
|
||||
TRANSPORT_STARTED = 'transport.started',
|
||||
TRANSPORT_COMPLETED = 'transport.completed',
|
||||
TRANSPORT_VERIFIED = 'transport.verified',
|
||||
|
||||
// Demand events
|
||||
DEMAND_CREATED = 'demand.created',
|
||||
DEMAND_MATCHED = 'demand.matched',
|
||||
SUPPLY_COMMITTED = 'supply.committed',
|
||||
|
||||
// Farm events
|
||||
FARM_REGISTERED = 'farm.registered',
|
||||
FARM_UPDATED = 'farm.updated',
|
||||
BATCH_STARTED = 'batch.started',
|
||||
BATCH_HARVESTED = 'batch.harvested',
|
||||
|
||||
// Agent events
|
||||
AGENT_ALERT = 'agent.alert',
|
||||
AGENT_TASK_COMPLETED = 'agent.task_completed',
|
||||
AGENT_ERROR = 'agent.error',
|
||||
|
||||
// Blockchain events
|
||||
BLOCKCHAIN_BLOCK_ADDED = 'blockchain.block_added',
|
||||
BLOCKCHAIN_VERIFIED = 'blockchain.verified',
|
||||
BLOCKCHAIN_ERROR = 'blockchain.error',
|
||||
|
||||
// System events
|
||||
SYSTEM_HEALTH = 'system.health',
|
||||
SYSTEM_ALERT = 'system.alert',
|
||||
SYSTEM_METRIC = 'system.metric',
|
||||
|
||||
// Audit events
|
||||
AUDIT_LOGGED = 'audit.logged',
|
||||
AUDIT_ANOMALY = 'audit.anomaly',
|
||||
}
|
||||
|
||||
/**
|
||||
* Map event types to their categories
|
||||
*/
|
||||
export const EVENT_CATEGORIES: Record<TransparencyEventType, EventCategory> = {
|
||||
'plant.registered': EventCategory.PLANT,
|
||||
'plant.cloned': EventCategory.PLANT,
|
||||
'plant.transferred': EventCategory.PLANT,
|
||||
'plant.updated': EventCategory.PLANT,
|
||||
'transport.started': EventCategory.TRANSPORT,
|
||||
'transport.completed': EventCategory.TRANSPORT,
|
||||
'transport.verified': EventCategory.TRANSPORT,
|
||||
'demand.created': EventCategory.DEMAND,
|
||||
'demand.matched': EventCategory.DEMAND,
|
||||
'supply.committed': EventCategory.DEMAND,
|
||||
'farm.registered': EventCategory.FARM,
|
||||
'farm.updated': EventCategory.FARM,
|
||||
'batch.started': EventCategory.FARM,
|
||||
'batch.harvested': EventCategory.FARM,
|
||||
'agent.alert': EventCategory.AGENT,
|
||||
'agent.task_completed': EventCategory.AGENT,
|
||||
'agent.error': EventCategory.AGENT,
|
||||
'blockchain.block_added': EventCategory.BLOCKCHAIN,
|
||||
'blockchain.verified': EventCategory.BLOCKCHAIN,
|
||||
'blockchain.error': EventCategory.BLOCKCHAIN,
|
||||
'system.health': EventCategory.SYSTEM,
|
||||
'system.alert': EventCategory.SYSTEM,
|
||||
'system.metric': EventCategory.SYSTEM,
|
||||
'audit.logged': EventCategory.AUDIT,
|
||||
'audit.anomaly': EventCategory.AUDIT,
|
||||
};
|
||||
|
||||
/**
|
||||
* Event display configuration
|
||||
*/
|
||||
interface EventDisplay {
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map event types to display properties
|
||||
*/
|
||||
export const EVENT_DISPLAY: Record<TransparencyEventType, EventDisplay> = {
|
||||
'plant.registered': { title: 'Plant Registered', icon: '🌱', color: 'green' },
|
||||
'plant.cloned': { title: 'Plant Cloned', icon: '🧬', color: 'green' },
|
||||
'plant.transferred': { title: 'Plant Transferred', icon: '🔄', color: 'blue' },
|
||||
'plant.updated': { title: 'Plant Updated', icon: '📝', color: 'gray' },
|
||||
'transport.started': { title: 'Transport Started', icon: '🚚', color: 'yellow' },
|
||||
'transport.completed': { title: 'Transport Completed', icon: '✅', color: 'green' },
|
||||
'transport.verified': { title: 'Transport Verified', icon: '🔍', color: 'blue' },
|
||||
'demand.created': { title: 'Demand Created', icon: '📊', color: 'purple' },
|
||||
'demand.matched': { title: 'Demand Matched', icon: '🎯', color: 'green' },
|
||||
'supply.committed': { title: 'Supply Committed', icon: '📦', color: 'blue' },
|
||||
'farm.registered': { title: 'Farm Registered', icon: '🏭', color: 'green' },
|
||||
'farm.updated': { title: 'Farm Updated', icon: '🔧', color: 'gray' },
|
||||
'batch.started': { title: 'Batch Started', icon: '🌿', color: 'green' },
|
||||
'batch.harvested': { title: 'Batch Harvested', icon: '🥬', color: 'green' },
|
||||
'agent.alert': { title: 'Agent Alert', icon: '⚠️', color: 'yellow' },
|
||||
'agent.task_completed': { title: 'Task Completed', icon: '✔️', color: 'green' },
|
||||
'agent.error': { title: 'Agent Error', icon: '❌', color: 'red' },
|
||||
'blockchain.block_added': { title: 'Block Added', icon: '🔗', color: 'blue' },
|
||||
'blockchain.verified': { title: 'Blockchain Verified', icon: '✓', color: 'green' },
|
||||
'blockchain.error': { title: 'Blockchain Error', icon: '⛓️💥', color: 'red' },
|
||||
'system.health': { title: 'Health Check', icon: '💓', color: 'green' },
|
||||
'system.alert': { title: 'System Alert', icon: '🔔', color: 'yellow' },
|
||||
'system.metric': { title: 'Metric Update', icon: '📈', color: 'blue' },
|
||||
'audit.logged': { title: 'Audit Logged', icon: '📋', color: 'gray' },
|
||||
'audit.anomaly': { title: 'Anomaly Detected', icon: '🚨', color: 'red' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the category for an event type
|
||||
*/
|
||||
export function getEventCategory(type: TransparencyEventType): EventCategory {
|
||||
return EVENT_CATEGORIES[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display properties for an event type
|
||||
*/
|
||||
export function getEventDisplay(type: TransparencyEventType): EventDisplay {
|
||||
return EVENT_DISPLAY[type] || { title: type, icon: '📌', color: 'gray' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a description for an event
|
||||
*/
|
||||
export function formatEventDescription(event: TransparencyEvent): string {
|
||||
const { type, data, source } = event;
|
||||
|
||||
switch (type) {
|
||||
case 'plant.registered':
|
||||
return `${data.name || 'A plant'} was registered by ${source}`;
|
||||
case 'plant.cloned':
|
||||
return `${data.name || 'A plant'} was cloned from ${data.parentName || 'parent'}`;
|
||||
case 'plant.transferred':
|
||||
return `${data.name || 'A plant'} was transferred to ${data.newOwner || 'new owner'}`;
|
||||
case 'plant.updated':
|
||||
return `${data.name || 'A plant'} information was updated`;
|
||||
case 'transport.started':
|
||||
return `Transport started from ${data.from || 'origin'} to ${data.to || 'destination'}`;
|
||||
case 'transport.completed':
|
||||
return `Transport completed: ${data.distance || '?'} km traveled`;
|
||||
case 'transport.verified':
|
||||
return `Transport verified on blockchain`;
|
||||
case 'demand.created':
|
||||
return `Demand signal created for ${data.product || 'product'}`;
|
||||
case 'demand.matched':
|
||||
return `Demand matched with ${data.supplier || 'a supplier'}`;
|
||||
case 'supply.committed':
|
||||
return `Supply committed: ${data.quantity || '?'} units`;
|
||||
case 'farm.registered':
|
||||
return `Vertical farm "${data.name || 'Farm'}" registered`;
|
||||
case 'farm.updated':
|
||||
return `Farm settings updated`;
|
||||
case 'batch.started':
|
||||
return `Growing batch started: ${data.cropType || 'crops'}`;
|
||||
case 'batch.harvested':
|
||||
return `Batch harvested: ${data.yield || '?'} kg`;
|
||||
case 'agent.alert':
|
||||
return data.message || 'Agent alert triggered';
|
||||
case 'agent.task_completed':
|
||||
return `Task completed: ${data.taskName || 'task'}`;
|
||||
case 'agent.error':
|
||||
return `Agent error: ${data.error || 'unknown error'}`;
|
||||
case 'blockchain.block_added':
|
||||
return `New block added to chain`;
|
||||
case 'blockchain.verified':
|
||||
return `Blockchain integrity verified`;
|
||||
case 'blockchain.error':
|
||||
return `Blockchain error: ${data.error || 'unknown error'}`;
|
||||
case 'system.health':
|
||||
return `System health: ${data.status || 'OK'}`;
|
||||
case 'system.alert':
|
||||
return data.message || 'System alert';
|
||||
case 'system.metric':
|
||||
return `Metric: ${data.metricName || 'metric'} = ${data.value || '?'}`;
|
||||
case 'audit.logged':
|
||||
return `Audit: ${data.action || 'action'} by ${data.actor || 'unknown'}`;
|
||||
case 'audit.anomaly':
|
||||
return `Anomaly: ${data.description || 'unusual activity detected'}`;
|
||||
default:
|
||||
return `Event from ${source}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a TransparencyEvent to a LiveFeedItem
|
||||
*/
|
||||
export function toFeedItem(event: TransparencyEvent): LiveFeedItem {
|
||||
const display = getEventDisplay(event.type);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
event,
|
||||
timestamp: new Date(event.timestamp).getTime(),
|
||||
formatted: {
|
||||
title: display.title,
|
||||
description: formatEventDescription(event),
|
||||
icon: display.icon,
|
||||
color: display.color,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by category
|
||||
*/
|
||||
export function getEventsByCategory(category: EventCategory): TransparencyEventType[] {
|
||||
return Object.entries(EVENT_CATEGORIES)
|
||||
.filter(([, cat]) => cat === category)
|
||||
.map(([type]) => type as TransparencyEventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event type belongs to a category
|
||||
*/
|
||||
export function isEventInCategory(type: TransparencyEventType, category: EventCategory): boolean {
|
||||
return EVENT_CATEGORIES[type] === category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority to numeric value for sorting
|
||||
*/
|
||||
export function priorityToNumber(priority: string): number {
|
||||
switch (priority) {
|
||||
case 'CRITICAL': return 4;
|
||||
case 'HIGH': return 3;
|
||||
case 'NORMAL': return 2;
|
||||
case 'LOW': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort events by priority and time
|
||||
*/
|
||||
export function sortEvents(events: TransparencyEvent[]): TransparencyEvent[] {
|
||||
return [...events].sort((a, b) => {
|
||||
const priorityDiff = priorityToNumber(b.priority) - priorityToNumber(a.priority);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* Real-Time Module for LocalGreenChain
|
||||
*
|
||||
* Provides WebSocket-based real-time updates using Socket.io
|
||||
* with automatic fallback to SSE.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
RoomType,
|
||||
ConnectionStatus,
|
||||
SocketAuthPayload,
|
||||
SocketHandshake,
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
InterServerEvents,
|
||||
SocketData,
|
||||
RealtimeNotification,
|
||||
ConnectionMetrics,
|
||||
LiveFeedItem,
|
||||
TransparencyEventType,
|
||||
EventPriority,
|
||||
TransparencyEvent,
|
||||
} from './types';
|
||||
|
||||
// Events
|
||||
export {
|
||||
EventCategory,
|
||||
RealtimeEvent,
|
||||
EVENT_CATEGORIES,
|
||||
EVENT_DISPLAY,
|
||||
getEventCategory,
|
||||
getEventDisplay,
|
||||
formatEventDescription,
|
||||
toFeedItem,
|
||||
getEventsByCategory,
|
||||
isEventInCategory,
|
||||
priorityToNumber,
|
||||
sortEvents,
|
||||
} from './events';
|
||||
|
||||
// Rooms
|
||||
export {
|
||||
parseRoom,
|
||||
createRoom,
|
||||
getDefaultRooms,
|
||||
getCategoryRoom,
|
||||
getEventRooms,
|
||||
isValidRoom,
|
||||
canJoinRoom,
|
||||
ROOM_LIMITS,
|
||||
} from './rooms';
|
||||
|
||||
// Server
|
||||
export {
|
||||
RealtimeSocketServer,
|
||||
getSocketServer,
|
||||
} from './socketServer';
|
||||
|
||||
// Client
|
||||
export {
|
||||
RealtimeSocketClient,
|
||||
getSocketClient,
|
||||
createSocketClient,
|
||||
} from './socketClient';
|
||||
export type {
|
||||
SocketClientConfig,
|
||||
EventListener,
|
||||
StatusListener,
|
||||
ErrorListener,
|
||||
} from './socketClient';
|
||||
|
||||
// React Hooks
|
||||
export {
|
||||
useSocket,
|
||||
useLiveFeed,
|
||||
usePlantUpdates,
|
||||
useFarmUpdates,
|
||||
useConnectionStatus,
|
||||
useEventCount,
|
||||
} from './useSocket';
|
||||
export type {
|
||||
UseSocketOptions,
|
||||
UseSocketReturn,
|
||||
} from './useSocket';
|
||||
|
||||
// React Context
|
||||
export {
|
||||
SocketProvider,
|
||||
useSocketContext,
|
||||
useOptionalSocketContext,
|
||||
} from './SocketContext';
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* Room Management for Socket.io
|
||||
*
|
||||
* Manages room subscriptions for targeted event delivery.
|
||||
*/
|
||||
|
||||
import type { RoomType, TransparencyEventType } from './types';
|
||||
import { EventCategory, getEventCategory } from './events';
|
||||
|
||||
/**
|
||||
* Parse a room type to extract its components
|
||||
*/
|
||||
export function parseRoom(room: RoomType): { type: string; id?: string } {
|
||||
if (room.includes(':')) {
|
||||
const [type, id] = room.split(':');
|
||||
return { type, id };
|
||||
}
|
||||
return { type: room };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room name for a specific entity
|
||||
*/
|
||||
export function createRoom(type: 'plant' | 'farm' | 'user', id: string): RoomType {
|
||||
return `${type}:${id}` as RoomType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default rooms for a user based on their role
|
||||
*/
|
||||
export function getDefaultRooms(userId?: string): RoomType[] {
|
||||
const rooms: RoomType[] = ['global'];
|
||||
if (userId) {
|
||||
rooms.push(`user:${userId}` as RoomType);
|
||||
}
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category-based room for an event type
|
||||
*/
|
||||
export function getCategoryRoom(type: TransparencyEventType): RoomType {
|
||||
const category = getEventCategory(type);
|
||||
|
||||
switch (category) {
|
||||
case EventCategory.PLANT:
|
||||
return 'plants';
|
||||
case EventCategory.TRANSPORT:
|
||||
return 'transport';
|
||||
case EventCategory.FARM:
|
||||
return 'farms';
|
||||
case EventCategory.DEMAND:
|
||||
return 'demand';
|
||||
case EventCategory.SYSTEM:
|
||||
case EventCategory.AGENT:
|
||||
case EventCategory.BLOCKCHAIN:
|
||||
case EventCategory.AUDIT:
|
||||
return 'system';
|
||||
default:
|
||||
return 'global';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine which rooms should receive an event
|
||||
*/
|
||||
export function getEventRooms(
|
||||
type: TransparencyEventType,
|
||||
data: Record<string, unknown>
|
||||
): RoomType[] {
|
||||
const rooms: RoomType[] = ['global'];
|
||||
|
||||
// Add category room
|
||||
const categoryRoom = getCategoryRoom(type);
|
||||
if (categoryRoom !== 'global') {
|
||||
rooms.push(categoryRoom);
|
||||
}
|
||||
|
||||
// Add entity-specific rooms
|
||||
if (data.plantId && typeof data.plantId === 'string') {
|
||||
rooms.push(`plant:${data.plantId}` as RoomType);
|
||||
}
|
||||
if (data.farmId && typeof data.farmId === 'string') {
|
||||
rooms.push(`farm:${data.farmId}` as RoomType);
|
||||
}
|
||||
if (data.userId && typeof data.userId === 'string') {
|
||||
rooms.push(`user:${data.userId}` as RoomType);
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a room is valid
|
||||
*/
|
||||
export function isValidRoom(room: string): room is RoomType {
|
||||
const validPrefixes = ['global', 'plants', 'transport', 'farms', 'demand', 'system'];
|
||||
const validEntityPrefixes = ['plant:', 'farm:', 'user:'];
|
||||
|
||||
if (validPrefixes.includes(room)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return validEntityPrefixes.some((prefix) => room.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Room subscription limits per connection
|
||||
*/
|
||||
export const ROOM_LIMITS = {
|
||||
maxRooms: 50,
|
||||
maxEntityRooms: 20,
|
||||
maxGlobalRooms: 10,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a connection can join another room
|
||||
*/
|
||||
export function canJoinRoom(currentRooms: RoomType[], newRoom: RoomType): boolean {
|
||||
if (currentRooms.length >= ROOM_LIMITS.maxRooms) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseRoom(newRoom);
|
||||
const entityRooms = currentRooms.filter((r) => r.includes(':')).length;
|
||||
const globalRooms = currentRooms.filter((r) => !r.includes(':')).length;
|
||||
|
||||
if (parsed.id && entityRooms >= ROOM_LIMITS.maxEntityRooms) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!parsed.id && globalRooms >= ROOM_LIMITS.maxGlobalRooms) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,379 +0,0 @@
|
|||
/**
|
||||
* Socket.io Client for LocalGreenChain
|
||||
*
|
||||
* Provides a client-side wrapper for Socket.io connections.
|
||||
*/
|
||||
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import type {
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
ConnectionStatus,
|
||||
RoomType,
|
||||
TransparencyEventType,
|
||||
TransparencyEvent,
|
||||
ConnectionMetrics,
|
||||
} from './types';
|
||||
|
||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
/**
|
||||
* Client configuration options
|
||||
*/
|
||||
export interface SocketClientConfig {
|
||||
url?: string;
|
||||
path?: string;
|
||||
autoConnect?: boolean;
|
||||
auth?: {
|
||||
userId?: string;
|
||||
token?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
reconnection?: boolean;
|
||||
reconnectionAttempts?: number;
|
||||
reconnectionDelay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener types
|
||||
*/
|
||||
export type EventListener = (event: TransparencyEvent) => void;
|
||||
export type StatusListener = (status: ConnectionStatus) => void;
|
||||
export type ErrorListener = (error: { code: string; message: string }) => void;
|
||||
|
||||
/**
|
||||
* Socket.io client wrapper
|
||||
*/
|
||||
class RealtimeSocketClient {
|
||||
private socket: TypedSocket | null = null;
|
||||
private config: SocketClientConfig;
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
private eventListeners: Set<EventListener> = new Set();
|
||||
private statusListeners: Set<StatusListener> = new Set();
|
||||
private errorListeners: Set<ErrorListener> = new Set();
|
||||
private metrics: ConnectionMetrics;
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(config: SocketClientConfig = {}) {
|
||||
this.config = {
|
||||
url: typeof window !== 'undefined' ? window.location.origin : '',
|
||||
path: '/api/socket',
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 10,
|
||||
reconnectionDelay: 1000,
|
||||
...config,
|
||||
};
|
||||
|
||||
this.metrics = {
|
||||
status: 'disconnected',
|
||||
eventsReceived: 0,
|
||||
reconnectAttempts: 0,
|
||||
rooms: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the server
|
||||
*/
|
||||
connect(): void {
|
||||
if (this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateStatus('connecting');
|
||||
|
||||
this.socket = io(this.config.url!, {
|
||||
path: this.config.path,
|
||||
autoConnect: this.config.autoConnect,
|
||||
auth: this.config.auth,
|
||||
reconnection: this.config.reconnection,
|
||||
reconnectionAttempts: this.config.reconnectionAttempts,
|
||||
reconnectionDelay: this.config.reconnectionDelay,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up socket event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.socket) return;
|
||||
|
||||
// Connection events
|
||||
this.socket.on('connect', () => {
|
||||
this.updateStatus('connected');
|
||||
this.metrics.connectedAt = Date.now();
|
||||
this.metrics.reconnectAttempts = 0;
|
||||
this.startPingInterval();
|
||||
});
|
||||
|
||||
this.socket.on('disconnect', () => {
|
||||
this.updateStatus('disconnected');
|
||||
this.stopPingInterval();
|
||||
});
|
||||
|
||||
this.socket.on('connect_error', () => {
|
||||
this.updateStatus('error');
|
||||
this.metrics.reconnectAttempts++;
|
||||
});
|
||||
|
||||
// Server events
|
||||
this.socket.on('connection:established', (data) => {
|
||||
console.log('[SocketClient] Connected:', data.socketId);
|
||||
});
|
||||
|
||||
this.socket.on('connection:error', (error) => {
|
||||
this.errorListeners.forEach((listener) => listener(error));
|
||||
});
|
||||
|
||||
// Real-time events
|
||||
this.socket.on('event', (event) => {
|
||||
this.metrics.eventsReceived++;
|
||||
this.metrics.lastEventAt = Date.now();
|
||||
this.eventListeners.forEach((listener) => listener(event));
|
||||
});
|
||||
|
||||
this.socket.on('event:batch', (events) => {
|
||||
this.metrics.eventsReceived += events.length;
|
||||
this.metrics.lastEventAt = Date.now();
|
||||
events.forEach((event) => {
|
||||
this.eventListeners.forEach((listener) => listener(event));
|
||||
});
|
||||
});
|
||||
|
||||
// Room events
|
||||
this.socket.on('room:joined', (room) => {
|
||||
if (!this.metrics.rooms.includes(room)) {
|
||||
this.metrics.rooms.push(room);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('room:left', (room) => {
|
||||
this.metrics.rooms = this.metrics.rooms.filter((r) => r !== room);
|
||||
});
|
||||
|
||||
// System events
|
||||
this.socket.on('system:message', (message) => {
|
||||
console.log(`[SocketClient] System ${message.type}: ${message.text}`);
|
||||
});
|
||||
|
||||
this.socket.on('system:heartbeat', () => {
|
||||
// Heartbeat received - connection is alive
|
||||
});
|
||||
|
||||
// Reconnection events
|
||||
this.socket.io.on('reconnect_attempt', () => {
|
||||
this.updateStatus('reconnecting');
|
||||
this.metrics.reconnectAttempts++;
|
||||
});
|
||||
|
||||
this.socket.io.on('reconnect', () => {
|
||||
this.updateStatus('connected');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping interval for latency measurement
|
||||
*/
|
||||
private startPingInterval(): void {
|
||||
this.stopPingInterval();
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.socket?.connected) {
|
||||
const start = Date.now();
|
||||
this.socket.emit('ping', (serverTime) => {
|
||||
this.metrics.latency = Date.now() - start;
|
||||
});
|
||||
}
|
||||
}, 10000); // Every 10 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ping interval
|
||||
*/
|
||||
private stopPingInterval(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status and notify listeners
|
||||
*/
|
||||
private updateStatus(status: ConnectionStatus): void {
|
||||
this.status = status;
|
||||
this.metrics.status = status;
|
||||
this.statusListeners.forEach((listener) => listener(status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.stopPingInterval();
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
this.updateStatus('disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a room
|
||||
*/
|
||||
joinRoom(room: RoomType): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.socket?.connected) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('room:join', room, (success) => {
|
||||
resolve(success);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a room
|
||||
*/
|
||||
leaveRoom(room: RoomType): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.socket?.connected) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('room:leave', room, (success) => {
|
||||
resolve(success);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to specific event types
|
||||
*/
|
||||
subscribeToTypes(types: TransparencyEventType[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.socket?.connected) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('subscribe:types', types, (success) => {
|
||||
resolve(success);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from specific event types
|
||||
*/
|
||||
unsubscribeFromTypes(types: TransparencyEventType[]): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.socket?.connected) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('unsubscribe:types', types, (success) => {
|
||||
resolve(success);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent events
|
||||
*/
|
||||
getRecentEvents(limit: number = 50): Promise<TransparencyEvent[]> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.socket?.connected) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.emit('events:recent', limit, (events) => {
|
||||
resolve(events);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an event listener
|
||||
*/
|
||||
onEvent(listener: EventListener): () => void {
|
||||
this.eventListeners.add(listener);
|
||||
return () => this.eventListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a status listener
|
||||
*/
|
||||
onStatusChange(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an error listener
|
||||
*/
|
||||
onError(listener: ErrorListener): () => void {
|
||||
this.errorListeners.add(listener);
|
||||
return () => this.errorListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current connection status
|
||||
*/
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection metrics
|
||||
*/
|
||||
getMetrics(): ConnectionMetrics {
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.socket?.connected ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get socket ID
|
||||
*/
|
||||
getSocketId(): string | undefined {
|
||||
return this.socket?.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance for client-side use
|
||||
let clientInstance: RealtimeSocketClient | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton socket client instance
|
||||
*/
|
||||
export function getSocketClient(config?: SocketClientConfig): RealtimeSocketClient {
|
||||
if (!clientInstance) {
|
||||
clientInstance = new RealtimeSocketClient(config);
|
||||
}
|
||||
return clientInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new socket client instance
|
||||
*/
|
||||
export function createSocketClient(config?: SocketClientConfig): RealtimeSocketClient {
|
||||
return new RealtimeSocketClient(config);
|
||||
}
|
||||
|
||||
export { RealtimeSocketClient };
|
||||
export default RealtimeSocketClient;
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
/**
|
||||
* Socket.io Server for LocalGreenChain
|
||||
*
|
||||
* Provides real-time WebSocket communication with automatic
|
||||
* fallback to SSE for environments that don't support WebSockets.
|
||||
*/
|
||||
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||
import type { Server as HTTPServer } from 'http';
|
||||
import type {
|
||||
ClientToServerEvents,
|
||||
ServerToClientEvents,
|
||||
InterServerEvents,
|
||||
SocketData,
|
||||
RoomType,
|
||||
TransparencyEventType,
|
||||
TransparencyEvent,
|
||||
} from './types';
|
||||
import { getEventStream } from '../transparency/EventStream';
|
||||
import { getEventRooms, isValidRoom, canJoinRoom, getDefaultRooms } from './rooms';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
type TypedSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
||||
|
||||
/**
|
||||
* Socket.io server configuration
|
||||
*/
|
||||
interface SocketServerConfig {
|
||||
cors?: {
|
||||
origin: string | string[];
|
||||
credentials?: boolean;
|
||||
};
|
||||
pingTimeout?: number;
|
||||
pingInterval?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.io server wrapper for LocalGreenChain
|
||||
*/
|
||||
class RealtimeSocketServer {
|
||||
private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData> | null = null;
|
||||
private eventStreamSubscriptionId: string | null = null;
|
||||
private connectedClients: Map<string, TypedSocket> = new Map();
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Initialize the Socket.io server
|
||||
*/
|
||||
initialize(httpServer: HTTPServer, config: SocketServerConfig = {}): void {
|
||||
if (this.io) {
|
||||
console.log('[RealtimeSocketServer] Already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.io = new SocketIOServer(httpServer, {
|
||||
path: '/api/socket',
|
||||
cors: config.cors || {
|
||||
origin: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||
credentials: true,
|
||||
},
|
||||
pingTimeout: config.pingTimeout || 60000,
|
||||
pingInterval: config.pingInterval || 25000,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
this.setupMiddleware();
|
||||
this.setupEventHandlers();
|
||||
this.subscribeToEventStream();
|
||||
this.startHeartbeat();
|
||||
|
||||
console.log('[RealtimeSocketServer] Initialized');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up authentication and rate limiting middleware
|
||||
*/
|
||||
private setupMiddleware(): void {
|
||||
if (!this.io) return;
|
||||
|
||||
// Authentication middleware
|
||||
this.io.use((socket, next) => {
|
||||
const auth = socket.handshake.auth;
|
||||
|
||||
// Generate session ID if not provided
|
||||
const sessionId = auth.sessionId || `sess_${crypto.randomBytes(8).toString('hex')}`;
|
||||
|
||||
// Attach data to socket
|
||||
socket.data = {
|
||||
userId: auth.userId,
|
||||
sessionId,
|
||||
connectedAt: Date.now(),
|
||||
rooms: new Set<RoomType>(),
|
||||
subscribedTypes: new Set<TransparencyEventType>(),
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up socket event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.io) return;
|
||||
|
||||
this.io.on('connection', (socket: TypedSocket) => {
|
||||
console.log(`[RealtimeSocketServer] Client connected: ${socket.id}`);
|
||||
|
||||
// Store connected client
|
||||
this.connectedClients.set(socket.id, socket);
|
||||
|
||||
// Join default rooms
|
||||
const defaultRooms = getDefaultRooms(socket.data.userId);
|
||||
defaultRooms.forEach((room) => {
|
||||
socket.join(room);
|
||||
socket.data.rooms.add(room);
|
||||
});
|
||||
|
||||
// Send connection established event
|
||||
socket.emit('connection:established', {
|
||||
socketId: socket.id,
|
||||
serverTime: Date.now(),
|
||||
});
|
||||
|
||||
// Handle room join
|
||||
socket.on('room:join', (room, callback) => {
|
||||
if (!isValidRoom(room)) {
|
||||
callback?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canJoinRoom(Array.from(socket.data.rooms), room)) {
|
||||
callback?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.join(room);
|
||||
socket.data.rooms.add(room);
|
||||
socket.emit('room:joined', room);
|
||||
callback?.(true);
|
||||
});
|
||||
|
||||
// Handle room leave
|
||||
socket.on('room:leave', (room, callback) => {
|
||||
socket.leave(room);
|
||||
socket.data.rooms.delete(room);
|
||||
socket.emit('room:left', room);
|
||||
callback?.(true);
|
||||
});
|
||||
|
||||
// Handle type subscriptions
|
||||
socket.on('subscribe:types', (types, callback) => {
|
||||
types.forEach((type) => socket.data.subscribedTypes.add(type));
|
||||
callback?.(true);
|
||||
});
|
||||
|
||||
// Handle type unsubscriptions
|
||||
socket.on('unsubscribe:types', (types, callback) => {
|
||||
types.forEach((type) => socket.data.subscribedTypes.delete(type));
|
||||
callback?.(true);
|
||||
});
|
||||
|
||||
// Handle ping for latency measurement
|
||||
socket.on('ping', (callback) => {
|
||||
callback(Date.now());
|
||||
});
|
||||
|
||||
// Handle recent events request
|
||||
socket.on('events:recent', (limit, callback) => {
|
||||
const eventStream = getEventStream();
|
||||
const events = eventStream.getRecent(Math.min(limit, 100));
|
||||
callback(events);
|
||||
});
|
||||
|
||||
// Handle disconnect
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.log(`[RealtimeSocketServer] Client disconnected: ${socket.id} (${reason})`);
|
||||
this.connectedClients.delete(socket.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the EventStream for broadcasting events
|
||||
*/
|
||||
private subscribeToEventStream(): void {
|
||||
const eventStream = getEventStream();
|
||||
|
||||
// Subscribe to all event types
|
||||
this.eventStreamSubscriptionId = eventStream.subscribe(
|
||||
eventStream.getAvailableEventTypes(),
|
||||
(event) => {
|
||||
this.broadcastEvent(event);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[RealtimeSocketServer] Subscribed to EventStream');
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to appropriate rooms
|
||||
*/
|
||||
private broadcastEvent(event: TransparencyEvent): void {
|
||||
if (!this.io) return;
|
||||
|
||||
// Get rooms that should receive this event
|
||||
const rooms = getEventRooms(event.type, event.data);
|
||||
|
||||
// Emit to each room
|
||||
rooms.forEach((room) => {
|
||||
this.io!.to(room).emit('event', event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat to keep connections alive
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (this.io) {
|
||||
this.io.emit('system:heartbeat', Date.now());
|
||||
}
|
||||
}, 30000); // Every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to specific rooms
|
||||
*/
|
||||
emitToRooms(rooms: RoomType[], eventName: keyof ServerToClientEvents, data: unknown): void {
|
||||
if (!this.io) return;
|
||||
|
||||
rooms.forEach((room) => {
|
||||
(this.io!.to(room) as any).emit(eventName, data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to a specific user
|
||||
*/
|
||||
emitToUser(userId: string, eventName: keyof ServerToClientEvents, data: unknown): void {
|
||||
this.emitToRooms([`user:${userId}` as RoomType], eventName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a system message to all connected clients
|
||||
*/
|
||||
emitSystemMessage(type: 'info' | 'warning' | 'error', text: string): void {
|
||||
if (!this.io) return;
|
||||
|
||||
this.io.emit('system:message', { type, text });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected client count
|
||||
*/
|
||||
getConnectedCount(): number {
|
||||
return this.connectedClients.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected socket IDs
|
||||
*/
|
||||
getConnectedSockets(): string[] {
|
||||
return Array.from(this.connectedClients.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get server stats
|
||||
*/
|
||||
getStats(): {
|
||||
connectedClients: number;
|
||||
rooms: string[];
|
||||
uptime: number;
|
||||
} {
|
||||
return {
|
||||
connectedClients: this.connectedClients.size,
|
||||
rooms: this.io ? Array.from(this.io.sockets.adapter.rooms.keys()) : [],
|
||||
uptime: process.uptime(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the server gracefully
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
if (this.eventStreamSubscriptionId) {
|
||||
const eventStream = getEventStream();
|
||||
eventStream.unsubscribe(this.eventStreamSubscriptionId);
|
||||
}
|
||||
|
||||
if (this.io) {
|
||||
// Notify all clients
|
||||
this.io.emit('system:message', {
|
||||
type: 'warning',
|
||||
text: 'Server is shutting down',
|
||||
});
|
||||
|
||||
// Disconnect all clients
|
||||
this.io.disconnectSockets(true);
|
||||
|
||||
// Close server
|
||||
await new Promise<void>((resolve) => {
|
||||
this.io!.close(() => {
|
||||
console.log('[RealtimeSocketServer] Shutdown complete');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
this.io = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Socket.io server instance
|
||||
*/
|
||||
getIO(): SocketIOServer | null {
|
||||
return this.io;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let socketServerInstance: RealtimeSocketServer | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton socket server instance
|
||||
*/
|
||||
export function getSocketServer(): RealtimeSocketServer {
|
||||
if (!socketServerInstance) {
|
||||
socketServerInstance = new RealtimeSocketServer();
|
||||
}
|
||||
return socketServerInstance;
|
||||
}
|
||||
|
||||
export { RealtimeSocketServer };
|
||||
export default RealtimeSocketServer;
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
/**
|
||||
* Real-Time System Types for LocalGreenChain
|
||||
*
|
||||
* Defines all types used in the WebSocket/SSE real-time communication system.
|
||||
*/
|
||||
|
||||
import type { TransparencyEventType, EventPriority, TransparencyEvent } from '../transparency/EventStream';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { TransparencyEventType, EventPriority, TransparencyEvent };
|
||||
|
||||
/**
|
||||
* Room types for Socket.io subscriptions
|
||||
*/
|
||||
export type RoomType =
|
||||
| 'global' // All events
|
||||
| 'plants' // All plant events
|
||||
| 'transport' // All transport events
|
||||
| 'farms' // All farm events
|
||||
| 'demand' // All demand events
|
||||
| 'system' // System events
|
||||
| `plant:${string}` // Specific plant
|
||||
| `farm:${string}` // Specific farm
|
||||
| `user:${string}`; // User-specific events
|
||||
|
||||
/**
|
||||
* Connection status states
|
||||
*/
|
||||
export type ConnectionStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'reconnecting'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* Socket authentication payload
|
||||
*/
|
||||
export interface SocketAuthPayload {
|
||||
userId?: string;
|
||||
token?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket handshake data
|
||||
*/
|
||||
export interface SocketHandshake {
|
||||
auth: SocketAuthPayload;
|
||||
rooms?: RoomType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-to-server events
|
||||
*/
|
||||
export interface ClientToServerEvents {
|
||||
// Room management
|
||||
'room:join': (room: RoomType, callback?: (success: boolean) => void) => void;
|
||||
'room:leave': (room: RoomType, callback?: (success: boolean) => void) => void;
|
||||
|
||||
// Event subscriptions
|
||||
'subscribe:types': (types: TransparencyEventType[], callback?: (success: boolean) => void) => void;
|
||||
'unsubscribe:types': (types: TransparencyEventType[], callback?: (success: boolean) => void) => void;
|
||||
|
||||
// Ping for connection health
|
||||
'ping': (callback: (timestamp: number) => void) => void;
|
||||
|
||||
// Request recent events
|
||||
'events:recent': (limit: number, callback: (events: TransparencyEvent[]) => void) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-to-client events
|
||||
*/
|
||||
export interface ServerToClientEvents {
|
||||
// Real-time events
|
||||
'event': (event: TransparencyEvent) => void;
|
||||
'event:batch': (events: TransparencyEvent[]) => void;
|
||||
|
||||
// Connection status
|
||||
'connection:established': (data: { socketId: string; serverTime: number }) => void;
|
||||
'connection:error': (error: { code: string; message: string }) => void;
|
||||
|
||||
// Room notifications
|
||||
'room:joined': (room: RoomType) => void;
|
||||
'room:left': (room: RoomType) => void;
|
||||
|
||||
// System messages
|
||||
'system:message': (message: { type: 'info' | 'warning' | 'error'; text: string }) => void;
|
||||
'system:heartbeat': (timestamp: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inter-server events (for scaling)
|
||||
*/
|
||||
export interface InterServerEvents {
|
||||
'event:broadcast': (event: TransparencyEvent, rooms: RoomType[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket data attached to each connection
|
||||
*/
|
||||
export interface SocketData {
|
||||
userId?: string;
|
||||
sessionId: string;
|
||||
connectedAt: number;
|
||||
rooms: Set<RoomType>;
|
||||
subscribedTypes: Set<TransparencyEventType>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time notification for UI display
|
||||
*/
|
||||
export interface RealtimeNotification {
|
||||
id: string;
|
||||
type: 'success' | 'info' | 'warning' | 'error';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
eventType?: TransparencyEventType;
|
||||
data?: Record<string, unknown>;
|
||||
read: boolean;
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection metrics for monitoring
|
||||
*/
|
||||
export interface ConnectionMetrics {
|
||||
status: ConnectionStatus;
|
||||
connectedAt?: number;
|
||||
lastEventAt?: number;
|
||||
eventsReceived: number;
|
||||
reconnectAttempts: number;
|
||||
latency?: number;
|
||||
rooms: RoomType[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Live feed item for display
|
||||
*/
|
||||
export interface LiveFeedItem {
|
||||
id: string;
|
||||
event: TransparencyEvent;
|
||||
timestamp: number;
|
||||
formatted: {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
/**
|
||||
* React Hook for Socket.io Real-Time Updates
|
||||
*
|
||||
* Provides easy-to-use hooks for real-time data in React components.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getSocketClient, RealtimeSocketClient } from './socketClient';
|
||||
import type {
|
||||
ConnectionStatus,
|
||||
TransparencyEvent,
|
||||
RoomType,
|
||||
TransparencyEventType,
|
||||
ConnectionMetrics,
|
||||
LiveFeedItem,
|
||||
} from './types';
|
||||
import { toFeedItem } from './events';
|
||||
|
||||
/**
|
||||
* Hook configuration options
|
||||
*/
|
||||
export interface UseSocketOptions {
|
||||
autoConnect?: boolean;
|
||||
rooms?: RoomType[];
|
||||
eventTypes?: TransparencyEventType[];
|
||||
userId?: string;
|
||||
maxEvents?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook return type
|
||||
*/
|
||||
export interface UseSocketReturn {
|
||||
status: ConnectionStatus;
|
||||
isConnected: boolean;
|
||||
events: TransparencyEvent[];
|
||||
latestEvent: TransparencyEvent | null;
|
||||
metrics: ConnectionMetrics;
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
joinRoom: (room: RoomType) => Promise<boolean>;
|
||||
leaveRoom: (room: RoomType) => Promise<boolean>;
|
||||
clearEvents: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main socket hook for real-time updates
|
||||
*/
|
||||
export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
|
||||
const {
|
||||
autoConnect = true,
|
||||
rooms = [],
|
||||
eventTypes = [],
|
||||
userId,
|
||||
maxEvents = 100,
|
||||
} = options;
|
||||
|
||||
const [status, setStatus] = useState<ConnectionStatus>('disconnected');
|
||||
const [events, setEvents] = useState<TransparencyEvent[]>([]);
|
||||
const [latestEvent, setLatestEvent] = useState<TransparencyEvent | null>(null);
|
||||
const [metrics, setMetrics] = useState<ConnectionMetrics>({
|
||||
status: 'disconnected',
|
||||
eventsReceived: 0,
|
||||
reconnectAttempts: 0,
|
||||
rooms: [],
|
||||
});
|
||||
|
||||
const clientRef = useRef<RealtimeSocketClient | null>(null);
|
||||
const cleanupRef = useRef<(() => void)[]>([]);
|
||||
|
||||
// Initialize client
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const client = getSocketClient({ auth: { userId } });
|
||||
clientRef.current = client;
|
||||
|
||||
// Set up listeners
|
||||
const unsubStatus = client.onStatusChange((newStatus) => {
|
||||
setStatus(newStatus);
|
||||
setMetrics(client.getMetrics());
|
||||
});
|
||||
|
||||
const unsubEvent = client.onEvent((event) => {
|
||||
setLatestEvent(event);
|
||||
setEvents((prev) => {
|
||||
const updated = [event, ...prev];
|
||||
return updated.slice(0, maxEvents);
|
||||
});
|
||||
setMetrics(client.getMetrics());
|
||||
});
|
||||
|
||||
cleanupRef.current = [unsubStatus, unsubEvent];
|
||||
|
||||
// Auto connect
|
||||
if (autoConnect) {
|
||||
client.connect();
|
||||
}
|
||||
|
||||
// Initial metrics
|
||||
setMetrics(client.getMetrics());
|
||||
|
||||
return () => {
|
||||
cleanupRef.current.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, [autoConnect, userId, maxEvents]);
|
||||
|
||||
// Join initial rooms
|
||||
useEffect(() => {
|
||||
if (!clientRef.current || status !== 'connected') return;
|
||||
|
||||
rooms.forEach((room) => {
|
||||
clientRef.current?.joinRoom(room);
|
||||
});
|
||||
}, [status, rooms]);
|
||||
|
||||
// Subscribe to event types
|
||||
useEffect(() => {
|
||||
if (!clientRef.current || status !== 'connected' || eventTypes.length === 0) return;
|
||||
|
||||
clientRef.current.subscribeToTypes(eventTypes);
|
||||
}, [status, eventTypes]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
clientRef.current?.connect();
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
clientRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
const joinRoom = useCallback(async (room: RoomType) => {
|
||||
return clientRef.current?.joinRoom(room) ?? false;
|
||||
}, []);
|
||||
|
||||
const leaveRoom = useCallback(async (room: RoomType) => {
|
||||
return clientRef.current?.leaveRoom(room) ?? false;
|
||||
}, []);
|
||||
|
||||
const clearEvents = useCallback(() => {
|
||||
setEvents([]);
|
||||
setLatestEvent(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
isConnected: status === 'connected',
|
||||
events,
|
||||
latestEvent,
|
||||
metrics,
|
||||
connect,
|
||||
disconnect,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
clearEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for live feed display
|
||||
*/
|
||||
export function useLiveFeed(options: UseSocketOptions = {}): {
|
||||
items: LiveFeedItem[];
|
||||
isConnected: boolean;
|
||||
status: ConnectionStatus;
|
||||
clearFeed: () => void;
|
||||
} {
|
||||
const { events, isConnected, status, clearEvents } = useSocket(options);
|
||||
|
||||
const items = events.map((event) => toFeedItem(event));
|
||||
|
||||
return {
|
||||
items,
|
||||
isConnected,
|
||||
status,
|
||||
clearFeed: clearEvents,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for tracking a specific plant's real-time updates
|
||||
*/
|
||||
export function usePlantUpdates(plantId: string): {
|
||||
events: TransparencyEvent[];
|
||||
isConnected: boolean;
|
||||
} {
|
||||
return useSocket({
|
||||
rooms: [`plant:${plantId}` as RoomType],
|
||||
eventTypes: [
|
||||
'plant.registered',
|
||||
'plant.cloned',
|
||||
'plant.transferred',
|
||||
'plant.updated',
|
||||
'transport.started',
|
||||
'transport.completed',
|
||||
],
|
||||
}) as { events: TransparencyEvent[]; isConnected: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for tracking a specific farm's real-time updates
|
||||
*/
|
||||
export function useFarmUpdates(farmId: string): {
|
||||
events: TransparencyEvent[];
|
||||
isConnected: boolean;
|
||||
} {
|
||||
return useSocket({
|
||||
rooms: [`farm:${farmId}` as RoomType],
|
||||
eventTypes: [
|
||||
'farm.registered',
|
||||
'farm.updated',
|
||||
'batch.started',
|
||||
'batch.harvested',
|
||||
'agent.alert',
|
||||
],
|
||||
}) as { events: TransparencyEvent[]; isConnected: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for connection status only (lightweight)
|
||||
*/
|
||||
export function useConnectionStatus(): {
|
||||
status: ConnectionStatus;
|
||||
isConnected: boolean;
|
||||
latency: number | undefined;
|
||||
} {
|
||||
const { status, isConnected, metrics } = useSocket({ autoConnect: true });
|
||||
|
||||
return {
|
||||
status,
|
||||
isConnected,
|
||||
latency: metrics.latency,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for event counts (useful for notification badges)
|
||||
*/
|
||||
export function useEventCount(options: UseSocketOptions = {}): {
|
||||
count: number;
|
||||
unreadCount: number;
|
||||
markAllRead: () => void;
|
||||
} {
|
||||
const { events } = useSocket(options);
|
||||
const [readCount, setReadCount] = useState(0);
|
||||
|
||||
const markAllRead = useCallback(() => {
|
||||
setReadCount(events.length);
|
||||
}, [events.length]);
|
||||
|
||||
return {
|
||||
count: events.length,
|
||||
unreadCount: Math.max(0, events.length - readCount),
|
||||
markAllRead,
|
||||
};
|
||||
}
|
||||
|
||||
export default useSocket;
|
||||
152
next.config.js
152
next.config.js
|
|
@ -1,157 +1,11 @@
|
|||
const withPWA = require('next-pwa')({
|
||||
dest: 'public',
|
||||
register: true,
|
||||
skipWaiting: true,
|
||||
disable: process.env.NODE_ENV === 'development',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-webfonts',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'google-fonts-stylesheets',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-font-assets',
|
||||
expiration: {
|
||||
maxEntries: 4,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-image-assets',
|
||||
expiration: {
|
||||
maxEntries: 64,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/_next\/image\?url=.+$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'next-image',
|
||||
expiration: {
|
||||
maxEntries: 64,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:mp3|wav|ogg)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
rangeRequests: true,
|
||||
cacheName: 'static-audio-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:mp4)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
rangeRequests: true,
|
||||
cacheName: 'static-video-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:js)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-js-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:css|less)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-style-assets',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/_next\/data\/.+\/.+\.json$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'next-data',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\/api\/.*$/i,
|
||||
handler: 'NetworkFirst',
|
||||
method: 'GET',
|
||||
options: {
|
||||
cacheName: 'apis',
|
||||
expiration: {
|
||||
maxEntries: 16,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'others',
|
||||
expiration: {
|
||||
maxEntries: 32,
|
||||
maxAgeSeconds: 24 * 60 * 60, // 1 day
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
module.exports = withPWA({
|
||||
module.exports = {
|
||||
swcMinify: true,
|
||||
i18n: {
|
||||
locales: ["en", "es"],
|
||||
defaultLocale: "en",
|
||||
},
|
||||
images: {
|
||||
domains: [process.env.NEXT_IMAGE_DOMAIN].filter(Boolean),
|
||||
domains: [process.env.NEXT_IMAGE_DOMAIN],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
|
|
@ -171,4 +25,4 @@ module.exports = withPWA({
|
|||
},
|
||||
]
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
8658
package-lock.json
generated
8658
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -28,9 +28,7 @@
|
|||
"db:seed": "bun run prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"prepare": "husky install",
|
||||
"validate": "bun run type-check && bun run lint && bun run test",
|
||||
"deploy:network-discovery": "bun run deploy/NetworkDiscoveryAgent.ts",
|
||||
"agent:network-discovery": "bun run deploy/NetworkDiscoveryAgent.ts"
|
||||
"validate": "bun run type-check && bun run lint && bun run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.937.0",
|
||||
|
|
@ -42,24 +40,17 @@
|
|||
"@tanstack/react-query": "^4.0.10",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"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",
|
||||
"idb": "^7.1.1",
|
||||
"multer": "^2.0.2",
|
||||
"next": "^12.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-drupal": "^1.6.0",
|
||||
"next-pwa": "^5.6.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-hook-form": "^7.8.6",
|
||||
"recharts": "^3.4.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.7.2",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"socks-proxy-agent": "^8.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -67,7 +58,6 @@
|
|||
"@commitlint/cli": "^18.4.3",
|
||||
"@commitlint/config-conventional": "^18.4.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^17.0.21",
|
||||
|
|
@ -85,8 +75,8 @@
|
|||
"prisma": "^5.7.0",
|
||||
"start-server-and-test": "^2.0.3",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-jest": "^29.4.5",
|
||||
"typescript": "^5.9.3"
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^4.5.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx}": [
|
||||
|
|
|
|||
|
|
@ -7,9 +7,6 @@ import { syncDrupalPreviewRoutes } from "next-drupal"
|
|||
import "nprogress/nprogress.css"
|
||||
|
||||
import "styles/globals.css"
|
||||
import "styles/mobile.css"
|
||||
|
||||
import { initPWA } from "lib/mobile/pwa"
|
||||
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
|
|
@ -25,13 +22,6 @@ export default function App({ Component, pageProps: { session, ...pageProps } })
|
|||
if (!queryClientRef.current) {
|
||||
queryClientRef.current = new QueryClient()
|
||||
}
|
||||
|
||||
// Initialize PWA
|
||||
React.useEffect(() => {
|
||||
const cleanup = initPWA()
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
|
|
|
|||
|
|
@ -1,253 +0,0 @@
|
|||
/**
|
||||
* Farm Analytics Page
|
||||
* Vertical farm performance and resource analytics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
KPICard,
|
||||
DateRangePicker,
|
||||
LineChart,
|
||||
BarChart,
|
||||
Gauge,
|
||||
DataTable,
|
||||
} from '../../components/analytics';
|
||||
import { TimeRange, FarmAnalytics } from '../../lib/analytics/types';
|
||||
|
||||
export default function FarmAnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<FarmAnalytics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analytics/farms?timeRange=${timeRange}`);
|
||||
const result = await response.json();
|
||||
setData(result.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch farm analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const zoneColumns = [
|
||||
{ key: 'zoneName', header: 'Zone' },
|
||||
{ key: 'currentCrop', header: 'Crop' },
|
||||
{
|
||||
key: 'healthScore',
|
||||
header: 'Health',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<span className={`font-medium ${v >= 90 ? 'text-green-600' : v >= 70 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{v}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'yieldKg', header: 'Yield (kg)', align: 'right' as const, render: (v: number) => v.toFixed(1) },
|
||||
{ key: 'efficiency', header: 'Efficiency', align: 'right' as const, render: (v: number) => `${v}%` },
|
||||
];
|
||||
|
||||
const cropColumns = [
|
||||
{ key: 'cropType', header: 'Crop' },
|
||||
{ key: 'batches', header: 'Batches', align: 'right' as const },
|
||||
{ key: 'averageYieldKg', header: 'Avg Yield (kg)', align: 'right' as const, render: (v: number) => v.toFixed(1) },
|
||||
{ key: 'growthDays', header: 'Growth Days', align: 'right' as const },
|
||||
{
|
||||
key: 'successRate',
|
||||
header: 'Success Rate',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<span className={`font-medium ${v >= 90 ? 'text-green-600' : v >= 75 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{v.toFixed(1)}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const predictionColumns = [
|
||||
{ key: 'cropType', header: 'Crop' },
|
||||
{ key: 'predictedYieldKg', header: 'Predicted Yield', align: 'right' as const, render: (v: number) => `${v.toFixed(1)} kg` },
|
||||
{ key: 'confidence', header: 'Confidence', align: 'right' as const, render: (v: number) => `${(v * 100).toFixed(0)}%` },
|
||||
{ key: 'harvestDate', header: 'Harvest Date' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-emerald-600 to-green-600 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold">Farm Analytics</h1>
|
||||
<p className="text-emerald-200 mt-1">Vertical farm performance and resource optimization</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-8">
|
||||
<DateRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex space-x-4 mb-8">
|
||||
<Link href="/analytics">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
|
||||
</Link>
|
||||
<Link href="/analytics/plants">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
|
||||
</Link>
|
||||
<Link href="/analytics/transport">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
|
||||
</Link>
|
||||
<Link href="/analytics/farms">
|
||||
<a className="px-4 py-2 bg-emerald-500 text-white rounded-lg font-medium">Farms</a>
|
||||
</Link>
|
||||
<Link href="/analytics/sustainability">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<KPICard
|
||||
title="Total Farms"
|
||||
value={data?.totalFarms || 0}
|
||||
trend="up"
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Active Zones"
|
||||
value={data?.totalZones || 0}
|
||||
trend="stable"
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Active Batches"
|
||||
value={data?.activeBatches || 0}
|
||||
trend="up"
|
||||
color="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Avg Yield"
|
||||
value={data?.averageYieldKg?.toFixed(1) || '0'}
|
||||
unit="kg/batch"
|
||||
trend="up"
|
||||
color="teal"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Gauges */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
|
||||
<Gauge
|
||||
value={data?.resourceUsage?.waterEfficiency || 0}
|
||||
title="Water Efficiency"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={data?.resourceUsage?.energyEfficiency || 0}
|
||||
title="Energy Efficiency"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={(data?.completedBatches || 0) / ((data?.activeBatches || 1) + (data?.completedBatches || 0)) * 100}
|
||||
title="Completion Rate"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={90}
|
||||
title="Overall Health"
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource Usage Stats */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Resource Usage Summary</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-blue-600">
|
||||
{data?.resourceUsage?.waterLiters?.toLocaleString() || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Water Used (L)</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-yellow-600">
|
||||
{data?.resourceUsage?.energyKwh?.toLocaleString() || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Energy Used (kWh)</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{data?.resourceUsage?.nutrientsKg?.toLocaleString() || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Nutrients Used (kg)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{data?.batchCompletionTrend && (
|
||||
<LineChart
|
||||
data={data.batchCompletionTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Batch Completions Over Time"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.topPerformingCrops && (
|
||||
<BarChart
|
||||
data={data.topPerformingCrops}
|
||||
xKey="cropType"
|
||||
yKey="averageYieldKg"
|
||||
title="Average Yield by Crop"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tables */}
|
||||
<div className="space-y-6">
|
||||
{data?.performanceByZone && (
|
||||
<DataTable
|
||||
data={data.performanceByZone}
|
||||
columns={zoneColumns}
|
||||
title="Zone Performance"
|
||||
pageSize={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.topPerformingCrops && (
|
||||
<DataTable
|
||||
data={data.topPerformingCrops}
|
||||
columns={cropColumns}
|
||||
title="Crop Performance"
|
||||
pageSize={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.yieldPredictions && (
|
||||
<DataTable
|
||||
data={data.yieldPredictions}
|
||||
columns={predictionColumns}
|
||||
title="Upcoming Harvest Predictions"
|
||||
pageSize={5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
/**
|
||||
* Analytics Dashboard - Main Page
|
||||
* Comprehensive overview of all analytics data
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
KPICard,
|
||||
DateRangePicker,
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
} from '../../components/analytics';
|
||||
import { TimeRange, AnalyticsOverview, PlantAnalytics, TransportAnalytics } from '../../lib/analytics/types';
|
||||
|
||||
export default function AnalyticsDashboard() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [plantData, setPlantData] = useState<PlantAnalytics | null>(null);
|
||||
const [transportData, setTransportData] = useState<TransportAnalytics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [overviewRes, plantRes, transportRes] = await Promise.all([
|
||||
fetch(`/api/analytics/overview?timeRange=${timeRange}`),
|
||||
fetch(`/api/analytics/plants?timeRange=${timeRange}`),
|
||||
fetch(`/api/analytics/transport?timeRange=${timeRange}`),
|
||||
]);
|
||||
|
||||
const overviewData = await overviewRes.json();
|
||||
const plantDataRes = await plantRes.json();
|
||||
const transportDataRes = await transportRes.json();
|
||||
|
||||
setOverview(overviewData.data);
|
||||
setPlantData(plantDataRes.data);
|
||||
setTransportData(transportDataRes.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = (format: 'csv' | 'json') => {
|
||||
window.open(`/api/analytics/export?format=${format}&timeRange=${timeRange}`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Analytics Dashboard</h1>
|
||||
<p className="text-green-200 mt-1">
|
||||
Comprehensive insights into your LocalGreenChain network
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => handleExport('csv')}
|
||||
className="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport('json')}
|
||||
className="px-4 py-2 bg-white/20 hover:bg-white/30 rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-8">
|
||||
<DateRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex space-x-4 mb-8">
|
||||
<Link href="/analytics">
|
||||
<a className="px-4 py-2 bg-green-500 text-white rounded-lg font-medium">Overview</a>
|
||||
</Link>
|
||||
<Link href="/analytics/plants">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
|
||||
</Link>
|
||||
<Link href="/analytics/transport">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
|
||||
</Link>
|
||||
<Link href="/analytics/farms">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
|
||||
</Link>
|
||||
<Link href="/analytics/sustainability">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<KPICard
|
||||
title="Total Plants"
|
||||
value={overview?.totalPlants || 0}
|
||||
trend={overview?.trendsData?.[0]?.direction || 'stable'}
|
||||
changePercent={overview?.trendsData?.[0]?.changePercent}
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Carbon Saved"
|
||||
value={transportData?.carbonSavedKg?.toFixed(1) || '0'}
|
||||
unit="kg CO2"
|
||||
trend="up"
|
||||
changePercent={12.5}
|
||||
color="teal"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Food Miles"
|
||||
value={transportData?.totalDistanceKm?.toFixed(0) || '0'}
|
||||
unit="km"
|
||||
trend="down"
|
||||
changePercent={-8.7}
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Active Users"
|
||||
value={overview?.activeUsers || 0}
|
||||
trend="up"
|
||||
changePercent={8.3}
|
||||
color="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Plant Registrations Trend */}
|
||||
{plantData?.registrationsTrend && (
|
||||
<LineChart
|
||||
data={plantData.registrationsTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Plant Registrations Over Time"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Species Distribution */}
|
||||
{plantData?.plantsBySpecies && (
|
||||
<PieChart
|
||||
data={plantData.plantsBySpecies}
|
||||
dataKey="count"
|
||||
nameKey="species"
|
||||
title="Plants by Species"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transport Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Transport Methods */}
|
||||
{transportData?.eventsByMethod && (
|
||||
<BarChart
|
||||
data={transportData.eventsByMethod}
|
||||
xKey="method"
|
||||
yKey="count"
|
||||
title="Transport Events by Method"
|
||||
height={300}
|
||||
horizontal
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Carbon Trend */}
|
||||
{transportData?.carbonTrend && (
|
||||
<LineChart
|
||||
data={transportData.carbonTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Carbon Emissions Trend"
|
||||
colors={['#ef4444']}
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Network Summary</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-gray-900">{overview?.plantsRegisteredToday || 0}</p>
|
||||
<p className="text-sm text-gray-500">Registered Today</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-gray-900">{overview?.plantsRegisteredThisWeek || 0}</p>
|
||||
<p className="text-sm text-gray-500">This Week</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-gray-900">{overview?.plantsRegisteredThisMonth || 0}</p>
|
||||
<p className="text-sm text-gray-500">This Month</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold text-green-600">{overview?.growthRate?.toFixed(1) || 0}%</p>
|
||||
<p className="text-sm text-gray-500">Growth Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
/**
|
||||
* Plant Analytics Page
|
||||
* Detailed analytics for plant registrations and lineage
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
KPICard,
|
||||
DateRangePicker,
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
DataTable,
|
||||
TrendIndicator,
|
||||
} from '../../components/analytics';
|
||||
import { TimeRange, PlantAnalytics } from '../../lib/analytics/types';
|
||||
|
||||
export default function PlantAnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<PlantAnalytics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analytics/plants?timeRange=${timeRange}`);
|
||||
const result = await response.json();
|
||||
setData(result.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plant analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const speciesColumns = [
|
||||
{ key: 'species', header: 'Species' },
|
||||
{ key: 'count', header: 'Count', align: 'right' as const },
|
||||
{ key: 'percentage', header: '% Share', align: 'right' as const, render: (v: number) => `${v.toFixed(1)}%` },
|
||||
{ key: 'trend', header: 'Trend', render: (v: string) => <TrendIndicator direction={v as any} /> },
|
||||
];
|
||||
|
||||
const growerColumns = [
|
||||
{ key: 'name', header: 'Grower' },
|
||||
{ key: 'totalPlants', header: 'Plants', align: 'right' as const },
|
||||
{ key: 'totalSpecies', header: 'Species', align: 'right' as const },
|
||||
{ key: 'averageGeneration', header: 'Avg Gen', align: 'right' as const, render: (v: number) => v.toFixed(1) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold">Plant Analytics</h1>
|
||||
<p className="text-green-200 mt-1">Detailed insights into plant registrations and lineage</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-8">
|
||||
<DateRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex space-x-4 mb-8">
|
||||
<Link href="/analytics">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
|
||||
</Link>
|
||||
<Link href="/analytics/plants">
|
||||
<a className="px-4 py-2 bg-green-500 text-white rounded-lg font-medium">Plants</a>
|
||||
</Link>
|
||||
<Link href="/analytics/transport">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
|
||||
</Link>
|
||||
<Link href="/analytics/farms">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
|
||||
</Link>
|
||||
<Link href="/analytics/sustainability">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<KPICard
|
||||
title="Total Plants"
|
||||
value={data?.totalPlants || 0}
|
||||
trend="up"
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Unique Species"
|
||||
value={data?.plantsBySpecies?.length || 0}
|
||||
trend="stable"
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Avg Lineage Depth"
|
||||
value={data?.averageLineageDepth?.toFixed(1) || '0'}
|
||||
unit="generations"
|
||||
trend="up"
|
||||
color="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Top Growers"
|
||||
value={data?.topGrowers?.length || 0}
|
||||
trend="stable"
|
||||
color="teal"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{data?.registrationsTrend && (
|
||||
<LineChart
|
||||
data={data.registrationsTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Plant Registrations Over Time"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.plantsBySpecies && (
|
||||
<PieChart
|
||||
data={data.plantsBySpecies}
|
||||
dataKey="count"
|
||||
nameKey="species"
|
||||
title="Distribution by Species"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generation Distribution */}
|
||||
{data?.plantsByGeneration && (
|
||||
<div className="mb-8">
|
||||
<BarChart
|
||||
data={data.plantsByGeneration}
|
||||
xKey="generation"
|
||||
yKey="count"
|
||||
title="Plants by Generation"
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tables */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{data?.plantsBySpecies && (
|
||||
<DataTable
|
||||
data={data.plantsBySpecies}
|
||||
columns={speciesColumns}
|
||||
title="Species Breakdown"
|
||||
pageSize={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.topGrowers && (
|
||||
<DataTable
|
||||
data={data.topGrowers}
|
||||
columns={growerColumns}
|
||||
title="Top Growers"
|
||||
pageSize={6}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
/**
|
||||
* Sustainability Analytics Page
|
||||
* Environmental impact and sustainability metrics
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
KPICard,
|
||||
DateRangePicker,
|
||||
LineChart,
|
||||
AreaChart,
|
||||
Gauge,
|
||||
DataTable,
|
||||
TrendIndicator,
|
||||
} from '../../components/analytics';
|
||||
import { TimeRange, SustainabilityAnalytics } from '../../lib/analytics/types';
|
||||
|
||||
export default function SustainabilityAnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<SustainabilityAnalytics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analytics/sustainability?timeRange=${timeRange}`);
|
||||
const result = await response.json();
|
||||
setData(result.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sustainability analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const goalColumns = [
|
||||
{ key: 'name', header: 'Goal' },
|
||||
{ key: 'current', header: 'Current', align: 'right' as const, render: (v: number, row: any) => `${v} ${row.unit}` },
|
||||
{ key: 'target', header: 'Target', align: 'right' as const, render: (v: number, row: any) => `${v} ${row.unit}` },
|
||||
{
|
||||
key: 'progress',
|
||||
header: 'Progress',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-20 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${v >= 90 ? 'bg-green-500' : v >= 70 ? 'bg-yellow-500' : 'bg-red-500'}`}
|
||||
style={{ width: `${Math.min(v, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm">{v}%</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (v: string) => (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
v === 'on_track'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: v === 'at_risk'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{v.replace('_', ' ')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-green-700 to-emerald-700 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold">Sustainability Analytics</h1>
|
||||
<p className="text-green-200 mt-1">Environmental impact and sustainability metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-8">
|
||||
<DateRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex space-x-4 mb-8">
|
||||
<Link href="/analytics">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
|
||||
</Link>
|
||||
<Link href="/analytics/plants">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
|
||||
</Link>
|
||||
<Link href="/analytics/transport">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Transport</a>
|
||||
</Link>
|
||||
<Link href="/analytics/farms">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
|
||||
</Link>
|
||||
<Link href="/analytics/sustainability">
|
||||
<a className="px-4 py-2 bg-green-700 text-white rounded-lg font-medium">Sustainability</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Overall Score */}
|
||||
<div className="bg-gradient-to-r from-green-500 to-emerald-500 rounded-lg p-8 mb-8 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Overall Sustainability Score</h2>
|
||||
<p className="text-green-100 mt-1">Based on carbon, local production, water, and waste metrics</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-bold">{data?.overallScore?.toFixed(0) || 0}</div>
|
||||
<div className="text-green-100">out of 100</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<KPICard
|
||||
title="Carbon Saved"
|
||||
value={data?.carbonFootprint?.totalSavedKg?.toFixed(0) || '0'}
|
||||
unit="kg CO2"
|
||||
trend="up"
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Reduction Rate"
|
||||
value={data?.carbonFootprint?.reductionPercentage?.toFixed(0) || '0'}
|
||||
unit="%"
|
||||
trend="up"
|
||||
color="teal"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Local Production"
|
||||
value={data?.localProduction?.percentage?.toFixed(0) || '0'}
|
||||
unit="%"
|
||||
trend="up"
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Water Efficiency"
|
||||
value={data?.waterUsage?.efficiencyScore?.toFixed(0) || '0'}
|
||||
unit="%"
|
||||
trend="up"
|
||||
color="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gauges */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
|
||||
<Gauge
|
||||
value={data?.carbonFootprint?.reductionPercentage || 0}
|
||||
title="Carbon Reduction"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={data?.foodMiles?.localPercentage || 0}
|
||||
title="Local Sourcing"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={data?.waterUsage?.efficiencyScore || 0}
|
||||
title="Water Efficiency"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={data?.overallScore || 0}
|
||||
title="Sustainability Score"
|
||||
unit="pts"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Impact Metrics */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-8">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Environmental Impact</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="text-center p-4 bg-green-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{data?.carbonFootprint?.equivalentTrees?.toFixed(1) || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Trees Equivalent</p>
|
||||
<p className="text-xs text-gray-400 mt-1">CO2 absorption per year</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-blue-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-blue-600">
|
||||
{data?.foodMiles?.savedMiles?.toLocaleString() || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Miles Saved</p>
|
||||
<p className="text-xs text-gray-400 mt-1">vs conventional transport</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-cyan-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-cyan-600">
|
||||
{data?.waterUsage?.savedLiters?.toLocaleString() || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Liters Saved</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Water conservation</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-purple-50 rounded-lg">
|
||||
<p className="text-3xl font-bold text-purple-600">
|
||||
{data?.localProduction?.localCount || 0}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Local Plants</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Within 50km radius</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{data?.carbonFootprint?.monthlyTrend && (
|
||||
<AreaChart
|
||||
data={data.carbonFootprint.monthlyTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Carbon Emissions Trend"
|
||||
colors={['#10b981']}
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.foodMiles?.monthlyTrend && (
|
||||
<AreaChart
|
||||
data={data.foodMiles.monthlyTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Food Miles Trend"
|
||||
colors={['#3b82f6']}
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sustainability Trends */}
|
||||
{data?.trends && data.trends.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<LineChart
|
||||
data={data.trends[0].values}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Sustainability Trend Over Time"
|
||||
colors={['#10b981', '#3b82f6']}
|
||||
height={250}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Goals Table */}
|
||||
{data?.goals && (
|
||||
<DataTable
|
||||
data={data.goals}
|
||||
columns={goalColumns}
|
||||
title="Sustainability Goals Progress"
|
||||
pageSize={10}
|
||||
showSearch={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tips Section */}
|
||||
<div className="mt-8 bg-gradient-to-r from-green-50 to-emerald-50 rounded-lg border border-green-200 p-6">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Sustainability Recommendations</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-green-500 mt-1">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-sm text-gray-600">Increase local sourcing to reduce transport emissions</p>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-green-500 mt-1">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-sm text-gray-600">Use electric or bicycle delivery for short distances</p>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-green-500 mt-1">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-sm text-gray-600">Optimize water usage in vertical farming operations</p>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-green-500 mt-1">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
<p className="text-sm text-gray-600">Consolidate deliveries to reduce carbon footprint</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
/**
|
||||
* Transport Analytics Page
|
||||
* Carbon footprint and food miles analysis
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
KPICard,
|
||||
DateRangePicker,
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
AreaChart,
|
||||
Gauge,
|
||||
DataTable,
|
||||
} from '../../components/analytics';
|
||||
import { TimeRange, TransportAnalytics } from '../../lib/analytics/types';
|
||||
|
||||
export default function TransportAnalyticsPage() {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<TransportAnalytics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [timeRange]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analytics/transport?timeRange=${timeRange}`);
|
||||
const result = await response.json();
|
||||
setData(result.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch transport analytics:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const methodColumns = [
|
||||
{ key: 'method', header: 'Method' },
|
||||
{ key: 'count', header: 'Events', align: 'right' as const },
|
||||
{ key: 'distanceKm', header: 'Distance (km)', align: 'right' as const, render: (v: number) => v.toLocaleString() },
|
||||
{ key: 'carbonKg', header: 'Carbon (kg)', align: 'right' as const, render: (v: number) => v.toFixed(2) },
|
||||
{
|
||||
key: 'efficiency',
|
||||
header: 'Efficiency',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<span className={`font-medium ${v >= 80 ? 'text-green-600' : v >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
|
||||
{v}%
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const routeColumns = [
|
||||
{ key: 'from', header: 'From' },
|
||||
{ key: 'to', header: 'To' },
|
||||
{ key: 'method', header: 'Method' },
|
||||
{ key: 'distanceKm', header: 'Distance', align: 'right' as const, render: (v: number) => `${v} km` },
|
||||
{ key: 'frequency', header: 'Frequency', align: 'right' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-teal-600 to-cyan-600 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold">Transport Analytics</h1>
|
||||
<p className="text-teal-200 mt-1">Carbon footprint and food miles analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-8">
|
||||
<DateRangePicker value={timeRange} onChange={setTimeRange} />
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex space-x-4 mb-8">
|
||||
<Link href="/analytics">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Overview</a>
|
||||
</Link>
|
||||
<Link href="/analytics/plants">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Plants</a>
|
||||
</Link>
|
||||
<Link href="/analytics/transport">
|
||||
<a className="px-4 py-2 bg-teal-500 text-white rounded-lg font-medium">Transport</a>
|
||||
</Link>
|
||||
<Link href="/analytics/farms">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Farms</a>
|
||||
</Link>
|
||||
<Link href="/analytics/sustainability">
|
||||
<a className="px-4 py-2 bg-white hover:bg-gray-50 text-gray-700 rounded-lg font-medium shadow">Sustainability</a>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<KPICard
|
||||
title="Total Events"
|
||||
value={data?.totalEvents || 0}
|
||||
trend="up"
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Total Distance"
|
||||
value={data?.totalDistanceKm?.toLocaleString() || '0'}
|
||||
unit="km"
|
||||
trend="stable"
|
||||
color="purple"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Carbon Emitted"
|
||||
value={data?.totalCarbonKg?.toFixed(1) || '0'}
|
||||
unit="kg CO2"
|
||||
trend="down"
|
||||
color="orange"
|
||||
loading={loading}
|
||||
/>
|
||||
<KPICard
|
||||
title="Carbon Saved"
|
||||
value={data?.carbonSavedKg?.toFixed(1) || '0'}
|
||||
unit="kg CO2"
|
||||
trend="up"
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gauges */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-8">
|
||||
<Gauge
|
||||
value={data?.totalCarbonKg ? (data.carbonSavedKg / (data.totalCarbonKg + data.carbonSavedKg)) * 100 : 0}
|
||||
title="Carbon Reduction"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={100 - ((data?.averageDistancePerEvent || 0) / 50) * 100}
|
||||
title="Distance Efficiency"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={data?.eventsByMethod?.filter(m => m.efficiency >= 80).length
|
||||
? (data.eventsByMethod.filter(m => m.efficiency >= 80).reduce((s, m) => s + m.count, 0) / data.totalEvents) * 100
|
||||
: 0}
|
||||
title="Green Transport"
|
||||
unit="%"
|
||||
/>
|
||||
<Gauge
|
||||
value={75}
|
||||
title="Local Sourcing"
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{data?.carbonTrend && (
|
||||
<AreaChart
|
||||
data={data.carbonTrend}
|
||||
xKey="label"
|
||||
yKey="value"
|
||||
title="Carbon Emissions Trend"
|
||||
colors={['#f59e0b']}
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.eventsByMethod && (
|
||||
<BarChart
|
||||
data={data.eventsByMethod}
|
||||
xKey="method"
|
||||
yKey="carbonKg"
|
||||
title="Carbon by Transport Method"
|
||||
height={300}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event Types Pie Chart */}
|
||||
{data?.eventsByType && (
|
||||
<div className="mb-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<PieChart
|
||||
data={data.eventsByType}
|
||||
dataKey="count"
|
||||
nameKey="eventType"
|
||||
title="Events by Type"
|
||||
height={300}
|
||||
/>
|
||||
<PieChart
|
||||
data={data.eventsByMethod}
|
||||
dataKey="distanceKm"
|
||||
nameKey="method"
|
||||
title="Distance by Method"
|
||||
height={300}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tables */}
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{data?.eventsByMethod && (
|
||||
<DataTable
|
||||
data={data.eventsByMethod}
|
||||
columns={methodColumns}
|
||||
title="Transport Method Breakdown"
|
||||
pageSize={8}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.mostEfficientRoutes && (
|
||||
<DataTable
|
||||
data={data.mostEfficientRoutes}
|
||||
columns={routeColumns}
|
||||
title="Most Efficient Routes"
|
||||
pageSize={5}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
/**
|
||||
* API Route: Get lineage analysis for a specific plant
|
||||
* GET /api/agents/lineage/[plantId]
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getPlantLineageAgent } from '../../../../lib/agents';
|
||||
import { getBlockchain } from '../../../../lib/blockchain/manager';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { plantId } = req.query;
|
||||
|
||||
if (!plantId || typeof plantId !== 'string') {
|
||||
return res.status(400).json({ success: false, error: 'Invalid plant ID' });
|
||||
}
|
||||
|
||||
// Check if plant exists in blockchain
|
||||
const blockchain = getBlockchain();
|
||||
const plant = blockchain.findPlant(plantId);
|
||||
|
||||
if (!plant) {
|
||||
return res.status(404).json({ success: false, error: 'Plant not found' });
|
||||
}
|
||||
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
// Get cached lineage analysis from agent
|
||||
let analysis = agent.getLineageAnalysis(plantId);
|
||||
|
||||
// If not cached, the agent hasn't scanned this plant yet
|
||||
// Return basic info with a note that full analysis is pending
|
||||
if (!analysis) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
plantId,
|
||||
cached: false,
|
||||
message: 'Lineage analysis pending. Agent will analyze on next scan cycle.',
|
||||
plant: {
|
||||
id: plant.id,
|
||||
species: plant.species,
|
||||
generation: plant.generation,
|
||||
status: plant.status,
|
||||
parentPlantId: plant.parentPlantId,
|
||||
childPlants: plant.childPlants || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
plantId,
|
||||
cached: true,
|
||||
analysis: {
|
||||
generation: analysis.generation,
|
||||
ancestors: analysis.ancestors,
|
||||
descendants: analysis.descendants,
|
||||
totalLineageSize: analysis.totalLineageSize,
|
||||
propagationChain: analysis.propagationChain,
|
||||
geographicSpread: analysis.geographicSpread,
|
||||
oldestAncestorDate: analysis.oldestAncestorDate,
|
||||
healthScore: analysis.healthScore,
|
||||
},
|
||||
plant: {
|
||||
id: plant.id,
|
||||
species: plant.species,
|
||||
generation: plant.generation,
|
||||
status: plant.status,
|
||||
propagationType: plant.propagationType,
|
||||
location: plant.location,
|
||||
dateAcquired: plant.dateAcquired,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting lineage analysis:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
/**
|
||||
* API Route: Get lineage anomalies detected by PlantLineageAgent
|
||||
* GET /api/agents/lineage/anomalies
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getPlantLineageAgent } from '../../../../lib/agents';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { severity, type, limit } = req.query;
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
let anomalies = agent.getAnomalies();
|
||||
|
||||
// Filter by severity if specified
|
||||
if (severity && typeof severity === 'string') {
|
||||
const validSeverities = ['low', 'medium', 'high'];
|
||||
if (!validSeverities.includes(severity)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid severity. Must be one of: ${validSeverities.join(', ')}`
|
||||
});
|
||||
}
|
||||
anomalies = anomalies.filter(a => a.severity === severity);
|
||||
}
|
||||
|
||||
// Filter by type if specified
|
||||
if (type && typeof type === 'string') {
|
||||
const validTypes = ['orphan', 'circular', 'invalid_generation', 'missing_parent', 'suspicious_location'];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid type. Must be one of: ${validTypes.join(', ')}`
|
||||
});
|
||||
}
|
||||
anomalies = anomalies.filter(a => a.type === type);
|
||||
}
|
||||
|
||||
// Apply limit if specified
|
||||
const maxResults = limit ? Math.min(parseInt(limit as string, 10), 100) : 50;
|
||||
anomalies = anomalies.slice(0, maxResults);
|
||||
|
||||
// Group by type for summary
|
||||
const byType: Record<string, number> = {};
|
||||
const bySeverity: Record<string, number> = {};
|
||||
const allAnomalies = agent.getAnomalies();
|
||||
|
||||
for (const anomaly of allAnomalies) {
|
||||
byType[anomaly.type] = (byType[anomaly.type] || 0) + 1;
|
||||
bySeverity[anomaly.severity] = (bySeverity[anomaly.severity] || 0) + 1;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
summary: {
|
||||
total: allAnomalies.length,
|
||||
byType,
|
||||
bySeverity,
|
||||
},
|
||||
filters: {
|
||||
severity: severity || null,
|
||||
type: type || null,
|
||||
limit: maxResults,
|
||||
},
|
||||
anomalies,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting lineage anomalies:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
/**
|
||||
* API Route: PlantLineageAgent Status and Network Stats
|
||||
* GET /api/agents/lineage - Get agent status and network statistics
|
||||
* POST /api/agents/lineage - Start/stop/restart the agent
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getPlantLineageAgent } from '../../../../lib/agents';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
const agent = getPlantLineageAgent();
|
||||
|
||||
if (req.method === 'GET') {
|
||||
try {
|
||||
const metrics = agent.getMetrics();
|
||||
const networkStats = agent.getNetworkStats();
|
||||
const anomalies = agent.getAnomalies();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
agent: {
|
||||
id: agent.config.id,
|
||||
name: agent.config.name,
|
||||
description: agent.config.description,
|
||||
status: agent.status,
|
||||
priority: agent.config.priority,
|
||||
intervalMs: agent.config.intervalMs,
|
||||
},
|
||||
metrics: {
|
||||
tasksCompleted: metrics.tasksCompleted,
|
||||
tasksFailed: metrics.tasksFailed,
|
||||
averageExecutionMs: Math.round(metrics.averageExecutionMs),
|
||||
lastRunAt: metrics.lastRunAt,
|
||||
lastSuccessAt: metrics.lastSuccessAt,
|
||||
uptime: metrics.uptime,
|
||||
},
|
||||
networkStats,
|
||||
anomalySummary: {
|
||||
total: anomalies.length,
|
||||
bySeverity: {
|
||||
high: anomalies.filter(a => a.severity === 'high').length,
|
||||
medium: anomalies.filter(a => a.severity === 'medium').length,
|
||||
low: anomalies.filter(a => a.severity === 'low').length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error getting PlantLineageAgent status:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
try {
|
||||
const { action } = req.body;
|
||||
|
||||
if (!action || !['start', 'stop', 'pause', 'resume'].includes(action)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid action. Must be one of: start, stop, pause, resume'
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
await agent.start();
|
||||
break;
|
||||
case 'stop':
|
||||
await agent.stop();
|
||||
break;
|
||||
case 'pause':
|
||||
agent.pause();
|
||||
break;
|
||||
case 'resume':
|
||||
agent.resume();
|
||||
break;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
action,
|
||||
newStatus: agent.status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error controlling PlantLineageAgent:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
} else {
|
||||
res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
/**
|
||||
* API Route: TransportTrackerAgent Analytics
|
||||
* GET /api/agents/transport-tracker/analytics - Get network stats and user analytics
|
||||
* GET /api/agents/transport-tracker/analytics?userId=xxx - Get specific user analytics
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getTransportTrackerAgent } from '../../../../lib/agents/TransportTrackerAgent';
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
return res.status(405).json({ success: false, error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = getTransportTrackerAgent();
|
||||
const { userId } = req.query;
|
||||
|
||||
// If userId provided, return user-specific analytics
|
||||
if (userId && typeof userId === 'string') {
|
||||
const userAnalysis = agent.getUserAnalysis(userId);
|
||||
|
||||
if (!userAnalysis) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `No analytics found for user: ${userId}`
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
user: userAnalysis,
|
||||
recommendations: userAnalysis.recommendations,
|
||||
efficiency: {
|
||||
rating: userAnalysis.efficiency,
|
||||
carbonPerKm: userAnalysis.carbonPerKm,
|
||||
totalCarbonKg: userAnalysis.totalCarbonKg
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Otherwise, return network-wide analytics
|
||||
const networkStats = agent.getNetworkStats();
|
||||
|
||||
if (!networkStats) {
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
message: 'No network statistics available yet. Run the agent to collect data.',
|
||||
networkStats: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
networkStats,
|
||||
insights: {
|
||||
avgCarbonPerEvent: networkStats.avgCarbonPerEvent,
|
||||
avgDistancePerEvent: networkStats.avgDistancePerEvent,
|
||||
greenTransportPercentage: networkStats.greenTransportPercentage,
|
||||
topMethods: Object.entries(networkStats.methodDistribution)
|
||||
.sort(([, a], [, b]) => (b as number) - (a as number))
|
||||
.slice(0, 5)
|
||||
.map(([method, count]) => ({ method, count }))
|
||||
},
|
||||
trends: networkStats.dailyTrends
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching transport tracker analytics:', error);
|
||||
res.status(500).json({ success: false, error: error.message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue