Add PWA support and implement mobile calculator component
This commit is contained in:
parent
4df64da3d4
commit
8cc4284140
@ -5,6 +5,14 @@
|
||||
<link rel="icon" type="image/webp" href="/puffinOffset.webp" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Puffin Calculator" />
|
||||
<link rel="apple-touch-icon" href="/puffinOffset.webp" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Puffin Offset - Carbon Offsetting for Superyachts</title>
|
||||
<meta name="title" content="Puffin Offset - Carbon Offsetting for Superyachts">
|
||||
|
||||
36
public/manifest.json
Normal file
36
public/manifest.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "Puffin Calculator",
|
||||
"short_name": "Puffin Calc",
|
||||
"description": "Carbon calculator for yacht owners - calculate and offset your carbon footprint",
|
||||
"start_url": "/mobile-app",
|
||||
"display": "standalone",
|
||||
"background_color": "#f1f5f9",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "portrait",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/puffinOffset.webp",
|
||||
"sizes": "192x192",
|
||||
"type": "image/webp",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/puffinOffset.webp",
|
||||
"sizes": "512x512",
|
||||
"type": "image/webp",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "business", "finance"],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/puffinOffset.webp",
|
||||
"sizes": "540x720",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
],
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
49
public/sw.js
Normal file
49
public/sw.js
Normal file
@ -0,0 +1,49 @@
|
||||
const CACHE_NAME = 'puffin-calculator-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/mobile-app',
|
||||
'/static/js/bundle.js',
|
||||
'/static/css/main.css',
|
||||
'/puffinOffset.webp',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Install event - cache resources
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then((cache) => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('Cache install failed:', error);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache when offline
|
||||
self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then((response) => {
|
||||
// Return cached version or fetch from network
|
||||
return response || fetch(event.request);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
38
src/App.tsx
38
src/App.tsx
@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Home } from './components/Home';
|
||||
import { YachtSearch } from './components/YachtSearch';
|
||||
import { TripCalculator } from './components/TripCalculator';
|
||||
import { MobileCalculator } from './components/MobileCalculator';
|
||||
import { HowItWorks } from './components/HowItWorks';
|
||||
import { About } from './components/About';
|
||||
import { Contact } from './components/Contact';
|
||||
@ -32,11 +33,27 @@ function App() {
|
||||
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
|
||||
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobileApp, setIsMobileApp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
analytics.pageView(window.location.pathname);
|
||||
// Check if we're on the mobile app route
|
||||
const path = window.location.pathname;
|
||||
setIsMobileApp(path === '/mobile-app');
|
||||
|
||||
analytics.pageView(path);
|
||||
}, [currentPage]);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle URL changes (for back/forward navigation)
|
||||
const handlePopState = () => {
|
||||
const path = window.location.pathname;
|
||||
setIsMobileApp(path === '/mobile-app');
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, []);
|
||||
|
||||
const handleSearch = async (imo: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -62,9 +79,17 @@ function App() {
|
||||
const handleNavigate = (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'contact') => {
|
||||
setCurrentPage(page);
|
||||
setMobileMenuOpen(false);
|
||||
setIsMobileApp(false);
|
||||
window.history.pushState({}, '', `/${page === 'home' ? '' : page}`);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleBackFromMobile = () => {
|
||||
setIsMobileApp(false);
|
||||
window.history.pushState({}, '', '/');
|
||||
setCurrentPage('home');
|
||||
};
|
||||
|
||||
const renderPage = () => {
|
||||
if (currentPage === 'calculator' && showOffsetOrder) {
|
||||
return (
|
||||
@ -114,6 +139,17 @@ function App() {
|
||||
}
|
||||
};
|
||||
|
||||
// If we're on the mobile app route, render only the mobile calculator
|
||||
if (isMobileApp) {
|
||||
return (
|
||||
<MobileCalculator
|
||||
vesselData={sampleVessel}
|
||||
onOffsetClick={handleOffsetClick}
|
||||
onBack={handleBackFromMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 wave-pattern">
|
||||
<header className="glass-nav shadow-luxury relative z-50 sticky top-0">
|
||||
|
||||
453
src/components/MobileCalculator.tsx
Normal file
453
src/components/MobileCalculator.tsx
Normal file
@ -0,0 +1,453 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Calculator, ArrowLeft, Zap, Route, DollarSign } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
|
||||
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
|
||||
import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currencies';
|
||||
import { CurrencySelect } from './CurrencySelect';
|
||||
import { PWAInstallPrompt } from './PWAInstallPrompt';
|
||||
|
||||
interface Props {
|
||||
vesselData: VesselData;
|
||||
onOffsetClick?: (tons: number, monetaryAmount?: number) => void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
||||
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>('fuel');
|
||||
const [distance, setDistance] = useState<string>('');
|
||||
const [speed, setSpeed] = useState<string>('12');
|
||||
const [fuelRate, setFuelRate] = useState<string>('100');
|
||||
const [fuelAmount, setFuelAmount] = useState<string>('');
|
||||
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
|
||||
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
|
||||
const [currency, setCurrency] = useState<CurrencyCode>('USD');
|
||||
const [offsetPercentage, setOffsetPercentage] = useState<number>(100);
|
||||
const [customPercentage, setCustomPercentage] = useState<string>('');
|
||||
const [customAmount, setCustomAmount] = useState<string>('');
|
||||
|
||||
const handleCalculate = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (calculationType === 'distance') {
|
||||
const estimate = calculateTripCarbon(
|
||||
vesselData,
|
||||
Number(distance),
|
||||
Number(speed),
|
||||
Number(fuelRate)
|
||||
);
|
||||
setTripEstimate(estimate);
|
||||
} else if (calculationType === 'fuel') {
|
||||
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
|
||||
setTripEstimate({
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
fuelConsumption: Number(fuelAmount),
|
||||
co2Emissions
|
||||
});
|
||||
}
|
||||
}, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]);
|
||||
|
||||
const handleCustomPercentageChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value === '' || (Number(value) >= 0 && Number(value) <= 100)) {
|
||||
setCustomPercentage(value);
|
||||
if (value !== '') {
|
||||
setOffsetPercentage(Number(value));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePresetPercentage = useCallback((percentage: number) => {
|
||||
setOffsetPercentage(percentage);
|
||||
setCustomPercentage('');
|
||||
}, []);
|
||||
|
||||
const calculateOffsetAmount = useCallback((emissions: number, percentage: number) => {
|
||||
return (emissions * percentage) / 100;
|
||||
}, []);
|
||||
|
||||
const handleCustomAmountChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (value === '' || Number(value) >= 0) {
|
||||
setCustomAmount(value);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const calculationMethods = [
|
||||
{ id: 'fuel', icon: Zap, label: 'Fuel Based', color: 'blue' },
|
||||
{ id: 'distance', icon: Route, label: 'Distance', color: 'green' },
|
||||
{ id: 'custom', icon: DollarSign, label: 'Custom Amount', color: 'purple' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50">
|
||||
{/* Mobile Header */}
|
||||
<motion.header
|
||||
className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-50"
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
{onBack && (
|
||||
<motion.button
|
||||
onClick={onBack}
|
||||
className="p-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</motion.button>
|
||||
)}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center">
|
||||
<Calculator className="text-white" size={16} />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-gray-900">Puffin Calculator</h1>
|
||||
</div>
|
||||
<div className="w-10"></div> {/* Spacer */}
|
||||
</div>
|
||||
</motion.header>
|
||||
|
||||
<div className="p-4 pb-20">
|
||||
{/* Method Selection */}
|
||||
<motion.div
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Select Calculation Method</h2>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{calculationMethods.map((method, index) => {
|
||||
const IconComponent = method.icon;
|
||||
const isActive = calculationType === method.id;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={method.id}
|
||||
onClick={() => setCalculationType(method.id as any)}
|
||||
className={`p-4 rounded-2xl border-2 transition-all ${
|
||||
isActive
|
||||
? 'border-blue-500 bg-blue-50 shadow-lg'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 + index * 0.1 }}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
<IconComponent size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className={`font-semibold ${isActive ? 'text-blue-900' : 'text-gray-900'}`}>
|
||||
{method.label}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{method.id === 'fuel' && 'Based on fuel consumption'}
|
||||
{method.id === 'distance' && 'Based on trip distance'}
|
||||
{method.id === 'custom' && 'Enter custom amount'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Input Forms */}
|
||||
<AnimatePresence mode="wait">
|
||||
{calculationType === 'custom' ? (
|
||||
<motion.div
|
||||
key="custom"
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4">Custom Amount</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<CurrencySelect value={currency} onChange={setCurrency} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Amount to Offset
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 text-lg">{currencies[currency].symbol}</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={customAmount}
|
||||
onChange={handleCustomAmountChange}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
className="w-full pl-10 pr-16 py-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 text-sm font-medium">{currency}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customAmount && Number(customAmount) > 0 && (
|
||||
<motion.button
|
||||
onClick={() => onOffsetClick?.(0, Number(customAmount))}
|
||||
className="w-full mt-6 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
Offset {formatCurrency(Number(customAmount), getCurrencyByCode(currency))}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="calculator"
|
||||
onSubmit={handleCalculate}
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{calculationType === 'fuel' ? 'Fuel Consumption' : 'Trip Details'}
|
||||
</h3>
|
||||
|
||||
{calculationType === 'fuel' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fuel Amount
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={fuelAmount}
|
||||
onChange={(e) => setFuelAmount(e.target.value)}
|
||||
placeholder="Enter fuel amount"
|
||||
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Unit
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{['liters', 'gallons'].map((unit) => (
|
||||
<button
|
||||
key={unit}
|
||||
type="button"
|
||||
onClick={() => setFuelUnit(unit as 'liters' | 'gallons')}
|
||||
className={`py-3 px-4 rounded-xl font-medium transition-colors ${
|
||||
fuelUnit === unit
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{unit.charAt(0).toUpperCase() + unit.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{calculationType === 'distance' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Distance (nautical miles)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={distance}
|
||||
onChange={(e) => setDistance(e.target.value)}
|
||||
placeholder="Enter distance"
|
||||
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Average Speed (knots)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={speed}
|
||||
onChange={(e) => setSpeed(e.target.value)}
|
||||
placeholder="Average speed"
|
||||
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fuel Rate (liters/hour)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={fuelRate}
|
||||
onChange={(e) => setFuelRate(e.target.value)}
|
||||
placeholder="Fuel consumption rate"
|
||||
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Typical: 50-500 L/h for most yachts
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Currency
|
||||
</label>
|
||||
<CurrencySelect value={currency} onChange={setCurrency} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full mt-6 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
Calculate Carbon Impact
|
||||
</button>
|
||||
</div>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Results */}
|
||||
<AnimatePresence>
|
||||
{tripEstimate && calculationType !== 'custom' && (
|
||||
<motion.div
|
||||
className="mt-6 space-y-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
{/* Results Summary */}
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4">Trip Impact</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
{calculationType === 'distance' && (
|
||||
<div className="bg-blue-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-blue-900">
|
||||
{tripEstimate.duration.toFixed(1)}h
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">Duration</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-green-50 p-4 rounded-xl text-center">
|
||||
<div className="text-2xl font-bold text-green-900">
|
||||
{tripEstimate.fuelConsumption.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">
|
||||
{fuelUnit} consumed
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 p-4 rounded-xl text-center">
|
||||
<div className="text-3xl font-bold text-red-900">
|
||||
{tripEstimate.co2Emissions.toFixed(2)} tons
|
||||
</div>
|
||||
<div className="text-sm text-red-600">CO₂ Emissions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offset Options */}
|
||||
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold mb-4">Offset Options</h3>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
{[100, 75, 50, 25].map((percent) => (
|
||||
<button
|
||||
key={percent}
|
||||
type="button"
|
||||
onClick={() => handlePresetPercentage(percent)}
|
||||
className={`py-3 px-2 rounded-xl font-medium text-sm transition-colors ${
|
||||
offsetPercentage === percent && customPercentage === ''
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{percent}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<input
|
||||
type="number"
|
||||
value={customPercentage}
|
||||
onChange={handleCustomPercentageChange}
|
||||
placeholder="Custom %"
|
||||
min="0"
|
||||
max="100"
|
||||
className="flex-1 py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<span className="text-gray-600 font-medium">%</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-xl mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-900">
|
||||
{calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">
|
||||
{offsetPercentage}% of total emissions
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onOffsetClick?.(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage))}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all"
|
||||
>
|
||||
Offset Your Impact
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* PWA Install Prompt */}
|
||||
<PWAInstallPrompt />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/components/PWAInstallPrompt.tsx
Normal file
148
src/components/PWAInstallPrompt.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Download, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[];
|
||||
readonly userChoice: Promise<{
|
||||
outcome: 'accepted' | 'dismissed';
|
||||
platform: string;
|
||||
}>;
|
||||
prompt(): Promise<void>;
|
||||
}
|
||||
|
||||
export function PWAInstallPrompt() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [showInstallPrompt, setShowInstallPrompt] = useState(false);
|
||||
const [isInstalled, setIsInstalled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if app is already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
||||
e.preventDefault();
|
||||
// Stash the event so it can be triggered later
|
||||
setDeferredPrompt(e as BeforeInstallPromptEvent);
|
||||
setShowInstallPrompt(true);
|
||||
};
|
||||
|
||||
const handleAppInstalled = () => {
|
||||
setIsInstalled(true);
|
||||
setShowInstallPrompt(false);
|
||||
setDeferredPrompt(null);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', handleAppInstalled);
|
||||
|
||||
// Show install prompt after a delay if supported
|
||||
const timer = setTimeout(() => {
|
||||
if (!isInstalled && !deferredPrompt) {
|
||||
// For iOS Safari or other browsers that don't support beforeinstallprompt
|
||||
if (/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream) {
|
||||
setShowInstallPrompt(true);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', handleAppInstalled);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isInstalled, deferredPrompt]);
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (!deferredPrompt) {
|
||||
// For iOS Safari, show manual install instructions
|
||||
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
|
||||
alert('To install this app on your iOS device, tap the Share button and then "Add to Home Screen".');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the install prompt
|
||||
deferredPrompt.prompt();
|
||||
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('User accepted the install prompt');
|
||||
} else {
|
||||
console.log('User dismissed the install prompt');
|
||||
}
|
||||
|
||||
setDeferredPrompt(null);
|
||||
setShowInstallPrompt(false);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowInstallPrompt(false);
|
||||
};
|
||||
|
||||
if (isInstalled || !showInstallPrompt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="fixed bottom-4 left-4 right-4 z-50"
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 100 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-gray-200 p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<img
|
||||
src="/puffinOffset.webp"
|
||||
alt="Puffin Calculator"
|
||||
className="w-8 h-8 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">
|
||||
Install Puffin Calculator
|
||||
</h3>
|
||||
<p className="text-gray-600 text-xs mt-1">
|
||||
Add to your home screen for quick access
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-1 rounded-lg hover:bg-gray-100 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X size={16} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<button
|
||||
onClick={handleInstallClick}
|
||||
className="flex-1 bg-blue-500 text-white rounded-xl py-2 px-4 font-medium text-sm hover:bg-blue-600 transition-colors flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Download size={16} />
|
||||
<span>Install</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors text-sm font-medium"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
15
src/main.tsx
15
src/main.tsx
@ -4,10 +4,23 @@ import App from './App.tsx';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import './index.css';
|
||||
|
||||
// Register service worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('SW registered: ', registration);
|
||||
})
|
||||
.catch((registrationError) => {
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user