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",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"framer-motion": "^12.15.0",
|
"framer-motion": "^12.15.0",
|
||||||
@ -16,10 +17,12 @@
|
|||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"next": "^16.0.1",
|
"next": "^16.0.1",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
@ -2064,6 +2067,15 @@
|
|||||||
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
|
||||||
"devOptional": true
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.11",
|
"version": "18.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz",
|
||||||
@ -2833,6 +2845,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||||
@ -2960,6 +2981,105 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"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": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz",
|
"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": "^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": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@ -4109,6 +4244,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/get-func-name": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||||
@ -4592,7 +4736,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@ -5536,6 +5679,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"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",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@ -5685,6 +5836,15 @@
|
|||||||
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
|
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@ -5919,6 +6079,23 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/querystringify": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
@ -6098,6 +6275,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
@ -6285,6 +6477,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"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"
|
"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": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
|
"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==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/yaml": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
|
||||||
@ -7568,6 +7778,134 @@
|
|||||||
"node": ">= 14"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
@ -7579,6 +7917,15 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"dependencies": {
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"framer-motion": "^12.15.0",
|
"framer-motion": "^12.15.0",
|
||||||
@ -19,10 +20,12 @@
|
|||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
"next": "^16.0.1",
|
"next": "^16.0.1",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^3.3.0",
|
"recharts": "^3.3.0",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { Bird, Menu, X } from 'lucide-react';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Home } from './components/Home';
|
import { Home } from './components/Home';
|
||||||
import { TripCalculator } from './components/TripCalculator';
|
import { TripCalculator } from './components/TripCalculator';
|
||||||
|
import { QRCalculatorLoader } from './components/QRCalculatorLoader';
|
||||||
|
import { useHasQRData } from './hooks/useQRDecoder';
|
||||||
import { HowItWorks } from './components/HowItWorks';
|
import { HowItWorks } from './components/HowItWorks';
|
||||||
import { About } from './components/About';
|
import { About } from './components/About';
|
||||||
import { Contact } from './components/Contact';
|
import { Contact } from './components/Contact';
|
||||||
@ -26,6 +28,7 @@ function App() {
|
|||||||
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
|
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
|
||||||
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
|
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const hasQRData = useHasQRData(); // Check if URL contains QR code data
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
analytics.pageView(window.location.pathname);
|
analytics.pageView(window.location.pathname);
|
||||||
@ -75,10 +78,17 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center w-full max-w-2xl space-y-8">
|
<div className="flex flex-col items-center w-full max-w-2xl space-y-8">
|
||||||
<TripCalculator
|
{hasQRData ? (
|
||||||
vesselData={sampleVessel}
|
<QRCalculatorLoader
|
||||||
onOffsetClick={handleOffsetClick}
|
vesselData={sampleVessel}
|
||||||
/>
|
onOffsetClick={handleOffsetClick}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TripCalculator
|
||||||
|
vesselData={sampleVessel}
|
||||||
|
onOffsetClick={handleOffsetClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 { useState, useCallback, useEffect } from 'react';
|
||||||
import { Leaf, Droplet } from 'lucide-react';
|
import { Leaf, Droplet } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
|
||||||
import { FormSelect } from './forms/FormSelect';
|
import { FormSelect } from './forms/FormSelect';
|
||||||
import { useCalculatorState } from '../hooks/useCalculatorState';
|
import { useCalculatorState } from '../hooks/useCalculatorState';
|
||||||
@ -9,18 +9,27 @@ import { useCalculatorState } from '../hooks/useCalculatorState';
|
|||||||
interface Props {
|
interface Props {
|
||||||
vesselData: VesselData;
|
vesselData: VesselData;
|
||||||
onOffsetClick?: (tons: number, monetaryAmount?: number) => void;
|
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 { state: savedState, saveState } = useCalculatorState();
|
||||||
|
|
||||||
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>(
|
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
|
// Format number with commas
|
||||||
const formatNumber = (value: string): string => {
|
const formatNumber = (value: string): string => {
|
||||||
@ -59,10 +68,12 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>(
|
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>(
|
||||||
savedState?.fuelUnit || 'liters'
|
initialData?.fuelUnit || savedState?.fuelUnit || 'liters'
|
||||||
);
|
);
|
||||||
const [tripEstimate, _setTripEstimate] = useState<TripEstimate | null>(null);
|
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) => {
|
const handleCalculate = useCallback((e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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
|
// Admin notes
|
||||||
notes?: string; // Internal 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