Add comprehensive QR code system for carbon calculator
Some checks failed
Build and Push Docker Images / docker (push) Failing after 2m11s
Some checks failed
Build and Push Docker Images / docker (push) Failing after 2m11s
Implements complete QR code generation and decoding system for pre-filling calculator data: - Add qrcode npm dependency (v1.5.4) and zod validation (v3.24.1) - Create QR generation API endpoint at /api/qr-code/generate - Implement Base64 URL-safe encoding/decoding utilities - Add Zod validation schemas for all calculator types (fuel, distance, custom) - Create QRCalculatorLoader wrapper component with loading/error states - Add useQRDecoder custom hooks for automatic URL parameter processing - Modify TripCalculator to accept initialData prop for pre-filling - Integrate QRCalculatorLoader into main App routing - Create test page at /qr-test for API testing and QR code visualization - Support all three calculator types with proper validation - Include vessel information (name, IMO) in QR data - Add 30-day expiration for generated QR codes - Provide PNG and SVG download options in test interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4e08e649da
commit
09eb2d3781
79
app/api/qr-code/generate/route.ts
Normal file
79
app/api/qr-code/generate/route.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* QR Code Generation API Endpoint
|
||||
* POST /api/qr-code/generate
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { QRCalculatorData, QRGenerationResponse } from '@/src/types';
|
||||
import { validateQRData, sanitizeQRData } from '@/src/utils/qrDataValidator';
|
||||
import { generateCalculatorQRCode } from '@/src/utils/qrCodeGenerator';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
|
||||
// Validate data
|
||||
const validationResult = validateQRData(body);
|
||||
if (!validationResult.valid) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: validationResult.error || 'Invalid QR data',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = validationResult.data as QRCalculatorData;
|
||||
|
||||
// Sanitize data to remove any unnecessary fields
|
||||
const cleanedData = sanitizeQRData(data);
|
||||
|
||||
// Get base URL from request
|
||||
const protocol = request.headers.get('x-forwarded-proto') || 'https';
|
||||
const host = request.headers.get('host') || 'localhost:3000';
|
||||
const baseUrl = `${protocol}://${host}`;
|
||||
|
||||
// Generate QR code
|
||||
const { dataURL, svg, url } = await generateCalculatorQRCode(cleanedData, baseUrl);
|
||||
|
||||
// Set expiration time (30 days from now)
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 30);
|
||||
|
||||
// Prepare response
|
||||
const response: QRGenerationResponse = {
|
||||
qrCodeDataURL: dataURL,
|
||||
qrCodeSVG: svg,
|
||||
url: url,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: response,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to generate QR code',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Return method not allowed for other HTTP methods
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Method not allowed. Use POST to generate QR codes.',
|
||||
},
|
||||
{ status: 405 }
|
||||
);
|
||||
}
|
||||
474
app/qr-test/page.tsx
Normal file
474
app/qr-test/page.tsx
Normal file
@ -0,0 +1,474 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { QRCalculatorData, QRGenerationResponse } from '@/src/types';
|
||||
import { Download, Copy, Check, Loader2, QrCode } from 'lucide-react';
|
||||
|
||||
export default function QRTestPage() {
|
||||
// Form state
|
||||
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>('distance');
|
||||
const [distance, setDistance] = useState('100');
|
||||
const [speed, setSpeed] = useState('12');
|
||||
const [fuelRate, setFuelRate] = useState('100');
|
||||
const [fuelAmount, setFuelAmount] = useState('500');
|
||||
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
|
||||
const [customAmount, setCustomAmount] = useState('5');
|
||||
const [vesselName, setVesselName] = useState('Test Yacht');
|
||||
const [imo, setImo] = useState('1234567');
|
||||
|
||||
// Response state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [response, setResponse] = useState<QRGenerationResponse | null>(null);
|
||||
const [copiedUrl, setCopiedUrl] = useState(false);
|
||||
const [copiedSvg, setCopiedSvg] = useState(false);
|
||||
|
||||
// Example presets
|
||||
const presets = {
|
||||
distance: {
|
||||
calculationType: 'distance' as const,
|
||||
distance: 100,
|
||||
speed: 12,
|
||||
fuelRate: 100,
|
||||
},
|
||||
fuel: {
|
||||
calculationType: 'fuel' as const,
|
||||
fuelAmount: 500,
|
||||
fuelUnit: 'liters' as const,
|
||||
},
|
||||
custom: {
|
||||
calculationType: 'custom' as const,
|
||||
customAmount: 5,
|
||||
},
|
||||
};
|
||||
|
||||
const loadPreset = (preset: keyof typeof presets) => {
|
||||
const data = presets[preset];
|
||||
setCalculationType(data.calculationType);
|
||||
|
||||
if ('distance' in data) {
|
||||
setDistance(data.distance.toString());
|
||||
setSpeed(data.speed!.toString());
|
||||
setFuelRate(data.fuelRate!.toString());
|
||||
}
|
||||
if ('fuelAmount' in data) {
|
||||
setFuelAmount(data.fuelAmount.toString());
|
||||
setFuelUnit(data.fuelUnit!);
|
||||
}
|
||||
if ('customAmount' in data) {
|
||||
setCustomAmount(data.customAmount.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setResponse(null);
|
||||
setCopiedUrl(false);
|
||||
setCopiedSvg(false);
|
||||
|
||||
try {
|
||||
// Build request data based on calculator type
|
||||
const requestData: QRCalculatorData = {
|
||||
calculationType,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'qr-test-page',
|
||||
vessel: vesselName || imo ? {
|
||||
name: vesselName || undefined,
|
||||
imo: imo || undefined,
|
||||
} : undefined,
|
||||
};
|
||||
|
||||
if (calculationType === 'distance') {
|
||||
requestData.distance = parseFloat(distance);
|
||||
requestData.speed = parseFloat(speed);
|
||||
requestData.fuelRate = parseFloat(fuelRate);
|
||||
} else if (calculationType === 'fuel') {
|
||||
requestData.fuelAmount = parseFloat(fuelAmount);
|
||||
requestData.fuelUnit = fuelUnit;
|
||||
} else if (calculationType === 'custom') {
|
||||
requestData.customAmount = parseFloat(customAmount);
|
||||
}
|
||||
|
||||
// Call API
|
||||
const res = await fetch('/api/qr-code/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to generate QR code');
|
||||
}
|
||||
|
||||
setResponse(result.data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, type: 'url' | 'svg') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
if (type === 'url') {
|
||||
setCopiedUrl(true);
|
||||
setTimeout(() => setCopiedUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedSvg(true);
|
||||
setTimeout(() => setCopiedSvg(false), 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQR = (dataURL: string, format: 'png' | 'svg') => {
|
||||
const link = document.createElement('a');
|
||||
link.href = format === 'png' ? dataURL : `data:image/svg+xml;base64,${btoa(response!.qrCodeSVG)}`;
|
||||
link.download = `qr-code-${calculationType}-${Date.now()}.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-green-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<QrCode className="w-12 h-12 text-blue-600 mr-3" />
|
||||
<h1 className="text-4xl font-bold text-gray-900">QR Code API Test Page</h1>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600">
|
||||
Test the QR code generation API for the carbon calculator
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Input Form */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Generator Configuration</h2>
|
||||
|
||||
{/* Example Presets */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quick Presets
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => loadPreset('distance')}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
Distance Example
|
||||
</button>
|
||||
<button
|
||||
onClick={() => loadPreset('fuel')}
|
||||
className="px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
Fuel Example
|
||||
</button>
|
||||
<button
|
||||
onClick={() => loadPreset('custom')}
|
||||
className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors text-sm font-medium"
|
||||
>
|
||||
Custom Example
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calculator Type */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Calculator Type
|
||||
</label>
|
||||
<select
|
||||
value={calculationType}
|
||||
onChange={(e) => setCalculationType(e.target.value as any)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="distance">Distance-based</option>
|
||||
<option value="fuel">Fuel-based</option>
|
||||
<option value="custom">Custom Amount</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Vessel Information */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Vessel Information (Optional)</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
Vessel Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={vesselName}
|
||||
onChange={(e) => setVesselName(e.target.value)}
|
||||
placeholder="e.g., Test Yacht"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
IMO Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={imo}
|
||||
onChange={(e) => setImo(e.target.value)}
|
||||
placeholder="e.g., 1234567"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{calculationType === 'distance' && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Distance (nautical miles)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={distance}
|
||||
onChange={(e) => setDistance(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Speed (knots)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={speed}
|
||||
onChange={(e) => setSpeed(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fuel Rate (liters/hour)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={fuelRate}
|
||||
onChange={(e) => setFuelRate(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calculationType === 'fuel' && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fuel Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={fuelAmount}
|
||||
onChange={(e) => setFuelAmount(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fuel Unit
|
||||
</label>
|
||||
<select
|
||||
value={fuelUnit}
|
||||
onChange={(e) => setFuelUnit(e.target.value as any)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="liters">Liters</option>
|
||||
<option value="gallons">Gallons</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calculationType === 'custom' && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Custom Amount (tons CO₂)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
'Generate QR Code'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-800 text-sm font-medium">Error: {error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Column - Results */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Generated QR Code</h2>
|
||||
|
||||
{!response && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-96 text-gray-400">
|
||||
<QrCode className="w-24 h-24 mb-4" />
|
||||
<p className="text-lg">No QR code generated yet</p>
|
||||
<p className="text-sm mt-2">Fill the form and click Generate</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-96">
|
||||
<Loader2 className="w-16 h-16 text-blue-600 animate-spin mb-4" />
|
||||
<p className="text-gray-600">Generating QR code...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{response && (
|
||||
<div className="space-y-6">
|
||||
{/* QR Code Display */}
|
||||
<div className="flex justify-center p-8 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={response.qrCodeDataURL}
|
||||
alt="Generated QR Code"
|
||||
className="max-w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Download Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => downloadQR(response.qrCodeDataURL, 'png')}
|
||||
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium flex items-center justify-center"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download PNG
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadQR(response.qrCodeDataURL, 'svg')}
|
||||
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium flex items-center justify-center"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download SVG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* URL Display */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Generated URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={response.url}
|
||||
readOnly
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(response.url, 'url')}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
|
||||
>
|
||||
{copiedUrl ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SVG Code */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
SVG Code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={response.qrCodeSVG}
|
||||
readOnly
|
||||
rows={6}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-xs font-mono resize-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(response.qrCodeSVG, 'svg')}
|
||||
className="absolute top-2 right-2 px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-xs flex items-center"
|
||||
>
|
||||
{copiedSvg ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 mr-1" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="p-4 bg-gray-50 rounded-lg text-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Metadata</h3>
|
||||
<div className="space-y-1 text-gray-600">
|
||||
<p><span className="font-medium">Expires:</span> {new Date(response.expiresAt).toLocaleString()}</p>
|
||||
<p><span className="font-medium">Type:</span> {calculationType}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Link */}
|
||||
<a
|
||||
href={response.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-center"
|
||||
>
|
||||
Test QR Code Link →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
353
package-lock.json
generated
353
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"axios": "^1.6.7",
|
||||
"dotenv": "^8.2.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
@ -16,10 +17,12 @@
|
||||
"lucide-react": "^0.344.0",
|
||||
"next": "^16.0.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.3.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
@ -2064,6 +2067,15 @@
|
||||
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz",
|
||||
@ -2833,6 +2845,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@ -2960,6 +2981,105 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@ -3245,6 +3365,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
|
||||
@ -3374,6 +3503,12 @@
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@ -4109,6 +4244,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-func-name": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||
@ -4592,7 +4736,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -5536,6 +5679,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@ -5576,7 +5728,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@ -5685,6 +5836,15 @@
|
||||
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@ -5919,6 +6079,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
@ -6098,6 +6275,21 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@ -6285,6 +6477,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@ -7315,6 +7513,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
|
||||
@ -7556,6 +7760,12 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
||||
@ -7568,6 +7778,134 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
@ -7579,6 +7917,15 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"axios": "^1.6.7",
|
||||
"dotenv": "^8.2.0",
|
||||
"framer-motion": "^12.15.0",
|
||||
@ -19,10 +20,12 @@
|
||||
"lucide-react": "^0.344.0",
|
||||
"next": "^16.0.1",
|
||||
"papaparse": "^5.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^3.3.0",
|
||||
"xlsx": "^0.18.5"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
|
||||
@ -3,6 +3,8 @@ import { Bird, Menu, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Home } from './components/Home';
|
||||
import { TripCalculator } from './components/TripCalculator';
|
||||
import { QRCalculatorLoader } from './components/QRCalculatorLoader';
|
||||
import { useHasQRData } from './hooks/useQRDecoder';
|
||||
import { HowItWorks } from './components/HowItWorks';
|
||||
import { About } from './components/About';
|
||||
import { Contact } from './components/Contact';
|
||||
@ -26,6 +28,7 @@ function App() {
|
||||
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
|
||||
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const hasQRData = useHasQRData(); // Check if URL contains QR code data
|
||||
|
||||
useEffect(() => {
|
||||
analytics.pageView(window.location.pathname);
|
||||
@ -75,10 +78,17 @@ function App() {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center w-full max-w-2xl space-y-8">
|
||||
<TripCalculator
|
||||
vesselData={sampleVessel}
|
||||
onOffsetClick={handleOffsetClick}
|
||||
/>
|
||||
{hasQRData ? (
|
||||
<QRCalculatorLoader
|
||||
vesselData={sampleVessel}
|
||||
onOffsetClick={handleOffsetClick}
|
||||
/>
|
||||
) : (
|
||||
<TripCalculator
|
||||
vesselData={sampleVessel}
|
||||
onOffsetClick={handleOffsetClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
106
src/components/QRCalculatorLoader.tsx
Normal file
106
src/components/QRCalculatorLoader.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
/**
|
||||
* QRCalculatorLoader
|
||||
* Wrapper component that decodes QR data and renders TripCalculator with pre-filled values
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { QrCode, Loader2, AlertTriangle } from 'lucide-react';
|
||||
import { TripCalculator } from './TripCalculator';
|
||||
import { useQRDecoder, useClearQRParam } from '../hooks/useQRDecoder';
|
||||
import { getQRDataDescription } from '../utils/qrDataValidator';
|
||||
import type { VesselData } from '../types';
|
||||
|
||||
interface Props {
|
||||
vesselData: VesselData;
|
||||
onOffsetClick?: (tons: number, monetaryAmount?: number) => void;
|
||||
}
|
||||
|
||||
export function QRCalculatorLoader({ vesselData, onOffsetClick }: Props) {
|
||||
const { data, isLoading, error, hasQRData } = useQRDecoder();
|
||||
const clearQR = useClearQRParam();
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="luxury-card p-12 max-w-4xl w-full mt-8 flex flex-col items-center justify-center"
|
||||
>
|
||||
<Loader2 className="w-12 h-12 text-deep-sea-blue animate-spin mb-4" />
|
||||
<h2 className="text-2xl font-bold text-deep-sea-blue mb-2">Decoding QR Data...</h2>
|
||||
<p className="text-deep-sea-blue/70">Please wait while we prepare your calculator</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error || (hasQRData && !data)) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="luxury-card p-12 max-w-4xl w-full mt-8"
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-deep-sea-blue mb-2">Invalid QR Code</h2>
|
||||
<p className="text-deep-sea-blue/70 mb-6 max-w-md">
|
||||
{error || 'The QR code data could not be decoded. Please try scanning again or enter your details manually.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearQR();
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-6 py-3 bg-deep-sea-blue text-white rounded-lg hover:bg-deep-sea-blue/90 transition-colors font-medium"
|
||||
>
|
||||
Enter Details Manually
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// No QR data - shouldn't normally reach here but handle gracefully
|
||||
if (!data) {
|
||||
return <TripCalculator vesselData={vesselData} onOffsetClick={onOffsetClick} />;
|
||||
}
|
||||
|
||||
// Success - render calculator with QR data
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* QR Badge - shows data was pre-filled */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-4 flex items-center justify-center"
|
||||
>
|
||||
<div className="glass-effect px-4 py-2 rounded-full flex items-center space-x-2 border border-sea-green/30 bg-sea-green/10">
|
||||
<QrCode className="w-5 h-5 text-sea-green" />
|
||||
<span className="text-sm font-medium text-deep-sea-blue">
|
||||
Pre-filled from QR Code: {getQRDataDescription(data)}
|
||||
</span>
|
||||
<button
|
||||
onClick={clearQR}
|
||||
className="ml-2 text-deep-sea-blue/60 hover:text-deep-sea-blue transition-colors text-xs font-medium"
|
||||
title="Clear QR data"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Calculator with pre-filled data */}
|
||||
<TripCalculator
|
||||
vesselData={vesselData}
|
||||
onOffsetClick={onOffsetClick}
|
||||
initialData={data}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Leaf, Droplet } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { VesselData, TripEstimate } from '../types';
|
||||
import type { VesselData, TripEstimate, QRCalculatorData } from '../types';
|
||||
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
|
||||
import { FormSelect } from './forms/FormSelect';
|
||||
import { useCalculatorState } from '../hooks/useCalculatorState';
|
||||
@ -9,18 +9,27 @@ import { useCalculatorState } from '../hooks/useCalculatorState';
|
||||
interface Props {
|
||||
vesselData: VesselData;
|
||||
onOffsetClick?: (tons: number, monetaryAmount?: number) => void;
|
||||
initialData?: QRCalculatorData; // Optional QR code pre-fill data
|
||||
}
|
||||
|
||||
export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
||||
export function TripCalculator({ vesselData, onOffsetClick, initialData }: Props) {
|
||||
const { state: savedState, saveState } = useCalculatorState();
|
||||
|
||||
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>(
|
||||
savedState?.calculationType || 'fuel'
|
||||
initialData?.calculationType || savedState?.calculationType || 'fuel'
|
||||
);
|
||||
const [distance, setDistance] = useState<string>(
|
||||
initialData?.distance?.toString() || savedState?.distance || ''
|
||||
);
|
||||
const [speed, setSpeed] = useState<string>(
|
||||
initialData?.speed?.toString() || savedState?.speed || '12'
|
||||
);
|
||||
const [fuelRate, setFuelRate] = useState<string>(
|
||||
initialData?.fuelRate?.toString() || savedState?.fuelRate || '100'
|
||||
);
|
||||
const [fuelAmount, setFuelAmount] = useState<string>(
|
||||
initialData?.fuelAmount?.toString() || savedState?.fuelAmount || ''
|
||||
);
|
||||
const [distance, setDistance] = useState<string>(savedState?.distance || '');
|
||||
const [speed, setSpeed] = useState<string>(savedState?.speed || '12');
|
||||
const [fuelRate, setFuelRate] = useState<string>(savedState?.fuelRate || '100');
|
||||
const [fuelAmount, setFuelAmount] = useState<string>(savedState?.fuelAmount || '');
|
||||
|
||||
// Format number with commas
|
||||
const formatNumber = (value: string): string => {
|
||||
@ -59,10 +68,12 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
||||
}
|
||||
};
|
||||
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>(
|
||||
savedState?.fuelUnit || 'liters'
|
||||
initialData?.fuelUnit || savedState?.fuelUnit || 'liters'
|
||||
);
|
||||
const [tripEstimate, _setTripEstimate] = useState<TripEstimate | null>(null);
|
||||
const [customAmount, setCustomAmount] = useState<string>(savedState?.customAmount || '');
|
||||
const [customAmount, setCustomAmount] = useState<string>(
|
||||
initialData?.customAmount?.toString() || savedState?.customAmount || ''
|
||||
);
|
||||
|
||||
const handleCalculate = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
172
src/hooks/useQRDecoder.ts
Normal file
172
src/hooks/useQRDecoder.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* useQRDecoder Hook
|
||||
* Custom React hook for decoding QR calculator data from URL parameters
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { QRCalculatorData } from '../types';
|
||||
import { extractQRDataFromUrl } from '../utils/qrDataEncoder';
|
||||
import { validateQRData } from '../utils/qrDataValidator';
|
||||
|
||||
export interface QRDecoderResult {
|
||||
/**
|
||||
* Decoded and validated QR data
|
||||
*/
|
||||
data: QRCalculatorData | null;
|
||||
|
||||
/**
|
||||
* Whether QR data is currently being processed
|
||||
*/
|
||||
isLoading: boolean;
|
||||
|
||||
/**
|
||||
* Error message if decoding/validation failed
|
||||
*/
|
||||
error: string | null;
|
||||
|
||||
/**
|
||||
* Whether QR data was found in URL
|
||||
*/
|
||||
hasQRData: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to decode and validate QR calculator data from URL parameters
|
||||
* Automatically extracts 'qr' parameter from current URL
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function Calculator() {
|
||||
* const { data, isLoading, error, hasQRData } = useQRDecoder();
|
||||
*
|
||||
* if (isLoading) return <div>Loading...</div>;
|
||||
* if (error) return <div>Error: {error}</div>;
|
||||
* if (!hasQRData) return <div>No QR data found</div>;
|
||||
*
|
||||
* // Use data to pre-fill calculator
|
||||
* return <CalculatorForm initialData={data} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useQRDecoder(): QRDecoderResult {
|
||||
const [data, setData] = useState<QRCalculatorData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasQRData, setHasQRData] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const decodeQRData = () => {
|
||||
try {
|
||||
// Get URL search params
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Check if QR parameter exists
|
||||
const qrParam = searchParams.get('qr');
|
||||
if (!qrParam) {
|
||||
setHasQRData(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setHasQRData(true);
|
||||
|
||||
// Extract and decode QR data
|
||||
const decodedData = extractQRDataFromUrl(searchParams);
|
||||
|
||||
if (!decodedData) {
|
||||
setError('Failed to decode QR data');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate the decoded data
|
||||
const validationResult = validateQRData(decodedData);
|
||||
|
||||
if (!validationResult.valid) {
|
||||
setError(validationResult.error || 'Invalid QR data');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set validated data
|
||||
setData(validationResult.data || null);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error decoding QR data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Unknown error occurred');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
decodeQRData();
|
||||
}, []); // Run only once on mount
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
hasQRData,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if current URL contains QR data parameter
|
||||
* Useful for conditional rendering without full decode
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function App() {
|
||||
* const hasQR = useHasQRData();
|
||||
*
|
||||
* if (hasQR) {
|
||||
* return <QRCalculatorLoader />;
|
||||
* }
|
||||
*
|
||||
* return <NormalCalculator />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHasQRData(): boolean {
|
||||
const [hasQRData, setHasQRData] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
setHasQRData(searchParams.has('qr'));
|
||||
}, []);
|
||||
|
||||
return hasQRData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to remove QR parameter from URL without page reload
|
||||
* Useful after data has been extracted and stored in state
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function Calculator() {
|
||||
* const { data } = useQRDecoder();
|
||||
* const clearQR = useClearQRParam();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (data) {
|
||||
* // Data extracted successfully, clean up URL
|
||||
* clearQR();
|
||||
* }
|
||||
* }, [data]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useClearQRParam(): () => void {
|
||||
return () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('qr');
|
||||
|
||||
// Update URL without page reload
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
} catch (error) {
|
||||
console.error('Error clearing QR parameter:', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
43
src/types.ts
43
src/types.ts
@ -205,3 +205,46 @@ export interface OrderRecord {
|
||||
// Admin notes
|
||||
notes?: string; // Internal admin notes
|
||||
}
|
||||
|
||||
// QR Code System Types
|
||||
export interface QRCalculatorData {
|
||||
// Calculator mode
|
||||
calculationType: 'fuel' | 'distance' | 'custom';
|
||||
|
||||
// Distance-based inputs
|
||||
distance?: number; // nautical miles
|
||||
speed?: number; // knots
|
||||
fuelRate?: number; // liters/hour
|
||||
|
||||
// Fuel-based inputs
|
||||
fuelAmount?: number; // liters or gallons
|
||||
fuelUnit?: 'liters' | 'gallons';
|
||||
|
||||
// Custom amount
|
||||
customAmount?: number; // tons of CO2
|
||||
|
||||
// Vessel information (optional, for context)
|
||||
vessel?: {
|
||||
imo?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
enginePower?: number;
|
||||
};
|
||||
|
||||
// Metadata
|
||||
timestamp?: string; // ISO timestamp for tracking
|
||||
source?: string; // e.g., "marina-api", "broker-portal"
|
||||
}
|
||||
|
||||
export interface QRGenerationResponse {
|
||||
qrCodeDataURL: string; // PNG as data URL
|
||||
qrCodeSVG: string; // SVG markup
|
||||
url: string; // The full URL with encoded data
|
||||
expiresAt: string; // ISO timestamp
|
||||
}
|
||||
|
||||
export interface QRValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
data?: QRCalculatorData;
|
||||
}
|
||||
|
||||
262
src/utils/qrCodeGenerator.ts
Normal file
262
src/utils/qrCodeGenerator.ts
Normal file
@ -0,0 +1,262 @@
|
||||
/**
|
||||
* QR Code Generator
|
||||
* Wrapper around qrcode library for generating QR codes
|
||||
*/
|
||||
|
||||
import QRCode from 'qrcode';
|
||||
import { QRCalculatorData } from '../types';
|
||||
import { generateQRUrl } from './qrDataEncoder';
|
||||
|
||||
export interface QRCodeOptions {
|
||||
/**
|
||||
* QR code width/height in pixels
|
||||
* @default 300
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
/**
|
||||
* Error correction level
|
||||
* L: ~7% correction
|
||||
* M: ~15% correction
|
||||
* Q: ~25% correction
|
||||
* H: ~30% correction
|
||||
* @default 'M'
|
||||
*/
|
||||
errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
/**
|
||||
* Foreground color (dark modules)
|
||||
* @default '#1D2939' (deep-sea-blue)
|
||||
*/
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* Background color (light modules)
|
||||
* @default '#FFFFFF'
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
|
||||
/**
|
||||
* Margin around QR code in modules
|
||||
* @default 4
|
||||
*/
|
||||
margin?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<QRCodeOptions> = {
|
||||
size: 300,
|
||||
errorCorrectionLevel: 'M',
|
||||
color: '#1D2939', // deep-sea-blue from tailwind.config.js
|
||||
backgroundColor: '#FFFFFF',
|
||||
margin: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate QR code as PNG data URL
|
||||
* @param url URL to encode in QR code
|
||||
* @param options QR code generation options
|
||||
* @returns Promise resolving to data URL string
|
||||
*/
|
||||
export async function generateQRCodeDataURL(
|
||||
url: string,
|
||||
options: QRCodeOptions = {}
|
||||
): Promise<string> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
try {
|
||||
const dataURL = await QRCode.toDataURL(url, {
|
||||
width: opts.size,
|
||||
errorCorrectionLevel: opts.errorCorrectionLevel,
|
||||
color: {
|
||||
dark: opts.color,
|
||||
light: opts.backgroundColor,
|
||||
},
|
||||
margin: opts.margin,
|
||||
});
|
||||
|
||||
return dataURL;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code data URL:', error);
|
||||
throw new Error('Failed to generate QR code');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code as SVG string
|
||||
* @param url URL to encode in QR code
|
||||
* @param options QR code generation options
|
||||
* @returns Promise resolving to SVG markup string
|
||||
*/
|
||||
export async function generateQRCodeSVG(
|
||||
url: string,
|
||||
options: QRCodeOptions = {}
|
||||
): Promise<string> {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
try {
|
||||
const svg = await QRCode.toString(url, {
|
||||
type: 'svg',
|
||||
width: opts.size,
|
||||
errorCorrectionLevel: opts.errorCorrectionLevel,
|
||||
color: {
|
||||
dark: opts.color,
|
||||
light: opts.backgroundColor,
|
||||
},
|
||||
margin: opts.margin,
|
||||
});
|
||||
|
||||
return svg;
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code SVG:', error);
|
||||
throw new Error('Failed to generate QR code SVG');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code from calculator data
|
||||
* Returns both PNG data URL and SVG
|
||||
* @param data Calculator data to encode
|
||||
* @param baseUrl Base URL of the application
|
||||
* @param options QR code generation options
|
||||
* @returns Promise resolving to object with both formats and URL
|
||||
*/
|
||||
export async function generateCalculatorQRCode(
|
||||
data: QRCalculatorData,
|
||||
baseUrl: string = window.location.origin,
|
||||
options: QRCodeOptions = {}
|
||||
): Promise<{
|
||||
dataURL: string;
|
||||
svg: string;
|
||||
url: string;
|
||||
}> {
|
||||
// Generate the full URL with encoded data
|
||||
const url = generateQRUrl(data, baseUrl);
|
||||
|
||||
// Generate both formats
|
||||
const [dataURL, svg] = await Promise.all([
|
||||
generateQRCodeDataURL(url, options),
|
||||
generateQRCodeSVG(url, options),
|
||||
]);
|
||||
|
||||
return {
|
||||
dataURL,
|
||||
svg,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download QR code as PNG file
|
||||
* @param dataURL Data URL of the QR code
|
||||
* @param filename Filename for download
|
||||
*/
|
||||
export function downloadQRCode(dataURL: string, filename: string = 'qr-code.png'): void {
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataURL;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error downloading QR code:', error);
|
||||
throw new Error('Failed to download QR code');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy QR code to clipboard as image
|
||||
* @param dataURL Data URL of the QR code
|
||||
*/
|
||||
export async function copyQRCodeToClipboard(dataURL: string): Promise<void> {
|
||||
try {
|
||||
// Convert data URL to blob
|
||||
const response = await fetch(dataURL);
|
||||
const blob = await response.blob();
|
||||
|
||||
// Copy to clipboard
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error copying QR code to clipboard:', error);
|
||||
throw new Error('Failed to copy QR code to clipboard');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print QR code
|
||||
* @param dataURL Data URL of the QR code
|
||||
* @param title Optional title to display above QR code
|
||||
*/
|
||||
export function printQRCode(dataURL: string, title?: string): void {
|
||||
try {
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) {
|
||||
throw new Error('Failed to open print window');
|
||||
}
|
||||
|
||||
const doc = printWindow.document;
|
||||
|
||||
// Create document structure using DOM manipulation
|
||||
const htmlEl = doc.createElement('html');
|
||||
const headEl = doc.createElement('head');
|
||||
const bodyEl = doc.createElement('body');
|
||||
|
||||
// Set title
|
||||
const titleEl = doc.createElement('title');
|
||||
titleEl.textContent = `QR Code${title ? ` - ${title}` : ''}`;
|
||||
headEl.appendChild(titleEl);
|
||||
|
||||
// Add styles
|
||||
const styleEl = doc.createElement('style');
|
||||
styleEl.textContent = `
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #1D2939;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
`;
|
||||
headEl.appendChild(styleEl);
|
||||
|
||||
// Add title heading if provided
|
||||
if (title) {
|
||||
const h1El = doc.createElement('h1');
|
||||
h1El.textContent = title;
|
||||
bodyEl.appendChild(h1El);
|
||||
}
|
||||
|
||||
// Add QR code image
|
||||
const imgEl = doc.createElement('img');
|
||||
imgEl.src = dataURL;
|
||||
imgEl.alt = 'QR Code';
|
||||
bodyEl.appendChild(imgEl);
|
||||
|
||||
// Assemble document
|
||||
htmlEl.appendChild(headEl);
|
||||
htmlEl.appendChild(bodyEl);
|
||||
doc.documentElement.replaceWith(htmlEl);
|
||||
|
||||
// Wait for image to load before printing
|
||||
imgEl.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error printing QR code:', error);
|
||||
throw new Error('Failed to print QR code');
|
||||
}
|
||||
}
|
||||
108
src/utils/qrDataEncoder.ts
Normal file
108
src/utils/qrDataEncoder.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/**
|
||||
* QR Data Encoder/Decoder
|
||||
* Handles encoding and decoding of calculator data for QR codes
|
||||
*/
|
||||
|
||||
import { QRCalculatorData } from '../types';
|
||||
|
||||
/**
|
||||
* Encode calculator data to Base64 string for QR code
|
||||
* @param data Calculator data to encode
|
||||
* @returns Base64-encoded JSON string
|
||||
*/
|
||||
export function encodeQRData(data: QRCalculatorData): string {
|
||||
try {
|
||||
// Convert data to JSON string
|
||||
const jsonString = JSON.stringify(data);
|
||||
|
||||
// Encode to Base64 (URL-safe)
|
||||
const base64 = btoa(jsonString);
|
||||
|
||||
// Make URL-safe by replacing characters
|
||||
const urlSafe = base64
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
return urlSafe;
|
||||
} catch (error) {
|
||||
console.error('Error encoding QR data:', error);
|
||||
throw new Error('Failed to encode QR data');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode Base64 string from QR code back to calculator data
|
||||
* @param encoded Base64-encoded string
|
||||
* @returns Decoded calculator data or null if invalid
|
||||
*/
|
||||
export function decodeQRData(encoded: string): QRCalculatorData | null {
|
||||
try {
|
||||
// Restore Base64 padding and special characters
|
||||
let base64 = encoded
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
// Add padding if needed
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
|
||||
// Decode from Base64
|
||||
const jsonString = atob(base64);
|
||||
|
||||
// Parse JSON
|
||||
const data = JSON.parse(jsonString) as QRCalculatorData;
|
||||
|
||||
// Basic validation - ensure calculationType exists
|
||||
if (!data.calculationType || !['fuel', 'distance', 'custom'].includes(data.calculationType)) {
|
||||
console.error('Invalid calculator type in QR data');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error decoding QR data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate full calculator URL with encoded QR data
|
||||
* @param data Calculator data to encode
|
||||
* @param baseUrl Base URL of the application (e.g., 'https://puffinoffset.com')
|
||||
* @returns Full URL with encoded data as query parameter
|
||||
*/
|
||||
export function generateQRUrl(data: QRCalculatorData, baseUrl: string = window.location.origin): string {
|
||||
const encoded = encodeQRData(data);
|
||||
return `${baseUrl}/calculator?qr=${encoded}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract QR data from URL query parameters
|
||||
* @param url URL string or URLSearchParams object
|
||||
* @returns Decoded calculator data or null
|
||||
*/
|
||||
export function extractQRDataFromUrl(url: string | URLSearchParams): QRCalculatorData | null {
|
||||
try {
|
||||
let params: URLSearchParams;
|
||||
|
||||
if (typeof url === 'string') {
|
||||
// Parse URL string
|
||||
const urlObj = new URL(url);
|
||||
params = urlObj.searchParams;
|
||||
} else {
|
||||
params = url;
|
||||
}
|
||||
|
||||
const qrParam = params.get('qr');
|
||||
if (!qrParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeQRData(qrParam);
|
||||
} catch (error) {
|
||||
console.error('Error extracting QR data from URL:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
172
src/utils/qrDataValidator.ts
Normal file
172
src/utils/qrDataValidator.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* QR Data Validator
|
||||
* Zod schemas for validating QR calculator data
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { QRCalculatorData, QRValidationResult } from '../types';
|
||||
|
||||
// Vessel information schema (optional)
|
||||
const VesselSchema = z.object({
|
||||
imo: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
type: z.string().optional(),
|
||||
enginePower: z.number().positive().optional(),
|
||||
}).optional();
|
||||
|
||||
// Base schema with common fields
|
||||
const BaseQRDataSchema = z.object({
|
||||
calculationType: z.enum(['fuel', 'distance', 'custom']),
|
||||
vessel: VesselSchema,
|
||||
timestamp: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
});
|
||||
|
||||
// Fuel-based calculation schema
|
||||
const FuelCalculationSchema = BaseQRDataSchema.extend({
|
||||
calculationType: z.literal('fuel'),
|
||||
fuelAmount: z.number().positive('Fuel amount must be positive'),
|
||||
fuelUnit: z.enum(['liters', 'gallons']),
|
||||
// Other fields should not be present for fuel calculation
|
||||
distance: z.undefined().optional(),
|
||||
speed: z.undefined().optional(),
|
||||
fuelRate: z.undefined().optional(),
|
||||
customAmount: z.undefined().optional(),
|
||||
});
|
||||
|
||||
// Distance-based calculation schema
|
||||
const DistanceCalculationSchema = BaseQRDataSchema.extend({
|
||||
calculationType: z.literal('distance'),
|
||||
distance: z.number().positive('Distance must be positive'),
|
||||
speed: z.number().positive('Speed must be positive'),
|
||||
fuelRate: z.number().positive('Fuel rate must be positive'),
|
||||
// Other fields should not be present for distance calculation
|
||||
fuelAmount: z.undefined().optional(),
|
||||
fuelUnit: z.undefined().optional(),
|
||||
customAmount: z.undefined().optional(),
|
||||
});
|
||||
|
||||
// Custom amount calculation schema
|
||||
const CustomCalculationSchema = BaseQRDataSchema.extend({
|
||||
calculationType: z.literal('custom'),
|
||||
customAmount: z.number().positive('Custom amount must be positive'),
|
||||
// Other fields should not be present for custom calculation
|
||||
distance: z.undefined().optional(),
|
||||
speed: z.undefined().optional(),
|
||||
fuelRate: z.undefined().optional(),
|
||||
fuelAmount: z.undefined().optional(),
|
||||
fuelUnit: z.undefined().optional(),
|
||||
});
|
||||
|
||||
// Union schema that validates based on calculationType
|
||||
const QRCalculatorDataSchema = z.discriminatedUnion('calculationType', [
|
||||
FuelCalculationSchema,
|
||||
DistanceCalculationSchema,
|
||||
CustomCalculationSchema,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate QR calculator data against schema
|
||||
* @param data Data to validate
|
||||
* @returns Validation result with success status and errors
|
||||
*/
|
||||
export function validateQRData(data: unknown): QRValidationResult {
|
||||
try {
|
||||
const validated = QRCalculatorDataSchema.parse(data);
|
||||
return {
|
||||
valid: true,
|
||||
data: validated as QRCalculatorData,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errorMessages = error.errors.map((err) => {
|
||||
const path = err.path.join('.');
|
||||
return `${path ? path + ': ' : ''}${err.message}`;
|
||||
});
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: `Validation failed: ${errorMessages.join(', ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Unknown validation error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data has required fields for its calculation type
|
||||
* @param data QR calculator data
|
||||
* @returns True if data has all required fields
|
||||
*/
|
||||
export function hasRequiredFields(data: QRCalculatorData): boolean {
|
||||
switch (data.calculationType) {
|
||||
case 'fuel':
|
||||
return !!(data.fuelAmount && data.fuelUnit);
|
||||
case 'distance':
|
||||
return !!(data.distance && data.speed && data.fuelRate);
|
||||
case 'custom':
|
||||
return !!data.customAmount;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize QR data by removing undefined/null values
|
||||
* @param data QR calculator data
|
||||
* @returns Cleaned data object
|
||||
*/
|
||||
export function sanitizeQRData(data: QRCalculatorData): QRCalculatorData {
|
||||
const cleaned: any = {
|
||||
calculationType: data.calculationType,
|
||||
};
|
||||
|
||||
// Add type-specific fields
|
||||
if (data.calculationType === 'fuel' && data.fuelAmount && data.fuelUnit) {
|
||||
cleaned.fuelAmount = data.fuelAmount;
|
||||
cleaned.fuelUnit = data.fuelUnit;
|
||||
} else if (data.calculationType === 'distance' && data.distance && data.speed && data.fuelRate) {
|
||||
cleaned.distance = data.distance;
|
||||
cleaned.speed = data.speed;
|
||||
cleaned.fuelRate = data.fuelRate;
|
||||
} else if (data.calculationType === 'custom' && data.customAmount) {
|
||||
cleaned.customAmount = data.customAmount;
|
||||
}
|
||||
|
||||
// Add optional vessel info if present
|
||||
if (data.vessel) {
|
||||
cleaned.vessel = {};
|
||||
if (data.vessel.imo) cleaned.vessel.imo = data.vessel.imo;
|
||||
if (data.vessel.name) cleaned.vessel.name = data.vessel.name;
|
||||
if (data.vessel.type) cleaned.vessel.type = data.vessel.type;
|
||||
if (data.vessel.enginePower) cleaned.vessel.enginePower = data.vessel.enginePower;
|
||||
}
|
||||
|
||||
// Add metadata if present
|
||||
if (data.timestamp) cleaned.timestamp = data.timestamp;
|
||||
if (data.source) cleaned.source = data.source;
|
||||
|
||||
return cleaned as QRCalculatorData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of QR data
|
||||
* @param data QR calculator data
|
||||
* @returns String description of the calculation
|
||||
*/
|
||||
export function getQRDataDescription(data: QRCalculatorData): string {
|
||||
switch (data.calculationType) {
|
||||
case 'fuel':
|
||||
return `Fuel-based: ${data.fuelAmount} ${data.fuelUnit}`;
|
||||
case 'distance':
|
||||
return `Distance-based: ${data.distance} nm at ${data.speed} knots`;
|
||||
case 'custom':
|
||||
return `Custom: ${data.customAmount} tons CO₂`;
|
||||
default:
|
||||
return 'Unknown calculation type';
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user