diff --git a/index.html b/index.html index a493bdd..3e8a1f6 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,14 @@ + + + + + + + + Puffin Offset - Carbon Offsetting for Superyachts diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..9bbf7d8 --- /dev/null +++ b/public/manifest.json @@ -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 +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..0c4e244 --- /dev/null +++ b/public/sw.js @@ -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); + } + }) + ); + }) + ); +}); diff --git a/src/App.tsx b/src/App.tsx index 5f1d2fb..c58e832 100644 --- a/src/App.tsx +++ b/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(); const [calculatorType, setCalculatorType] = useState('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 ( + + ); + } + return (
diff --git a/src/components/MobileCalculator.tsx b/src/components/MobileCalculator.tsx new file mode 100644 index 0000000..b264c70 --- /dev/null +++ b/src/components/MobileCalculator.tsx @@ -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(''); + const [speed, setSpeed] = useState('12'); + const [fuelRate, setFuelRate] = useState('100'); + const [fuelAmount, setFuelAmount] = useState(''); + const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters'); + const [tripEstimate, setTripEstimate] = useState(null); + const [currency, setCurrency] = useState('USD'); + const [offsetPercentage, setOffsetPercentage] = useState(100); + const [customPercentage, setCustomPercentage] = useState(''); + const [customAmount, setCustomAmount] = useState(''); + + 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) => { + 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) => { + 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 ( +
+ {/* Mobile Header */} + +
+ {onBack && ( + + + + )} +
+
+ +
+

Puffin Calculator

+
+
{/* Spacer */} +
+
+ +
+ {/* Method Selection */} + +

Select Calculation Method

+
+ {calculationMethods.map((method, index) => { + const IconComponent = method.icon; + const isActive = calculationType === method.id; + + return ( + 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 }} + > +
+
+ +
+
+
+ {method.label} +
+
+ {method.id === 'fuel' && 'Based on fuel consumption'} + {method.id === 'distance' && 'Based on trip distance'} + {method.id === 'custom' && 'Enter custom amount'} +
+
+
+
+ ); + })} +
+
+ + {/* Input Forms */} + + {calculationType === 'custom' ? ( + +
+

Custom Amount

+ +
+
+ + +
+ +
+ +
+
+ {currencies[currency].symbol} +
+ +
+ {currency} +
+
+
+
+ + {customAmount && Number(customAmount) > 0 && ( + 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))} + + )} +
+
+ ) : ( + +
+

+ {calculationType === 'fuel' ? 'Fuel Consumption' : 'Trip Details'} +

+ + {calculationType === 'fuel' && ( +
+
+ + 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 + /> +
+
+ +
+ {['liters', 'gallons'].map((unit) => ( + + ))} +
+
+
+ )} + + {calculationType === 'distance' && ( +
+
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +

+ Typical: 50-500 L/h for most yachts +

+
+
+ )} + +
+ + +
+ + +
+
+ )} +
+ + {/* Results */} + + {tripEstimate && calculationType !== 'custom' && ( + + {/* Results Summary */} +
+

Trip Impact

+ +
+ {calculationType === 'distance' && ( +
+
+ {tripEstimate.duration.toFixed(1)}h +
+
Duration
+
+ )} +
+
+ {tripEstimate.fuelConsumption.toLocaleString()} +
+
+ {fuelUnit} consumed +
+
+
+ +
+
+ {tripEstimate.co2Emissions.toFixed(2)} tons +
+
CO₂ Emissions
+
+
+ + {/* Offset Options */} +
+

Offset Options

+ +
+ {[100, 75, 50, 25].map((percent) => ( + + ))} +
+ +
+ + % +
+ +
+
+
+ {calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons +
+
+ {offsetPercentage}% of total emissions +
+
+
+ + +
+
+ )} +
+
+ + {/* PWA Install Prompt */} + +
+ ); +} diff --git a/src/components/PWAInstallPrompt.tsx b/src/components/PWAInstallPrompt.tsx new file mode 100644 index 0000000..fa47dc3 --- /dev/null +++ b/src/components/PWAInstallPrompt.tsx @@ -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; +} + +export function PWAInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(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 ( + + +
+
+
+
+ Puffin Calculator +
+
+

+ Install Puffin Calculator +

+

+ Add to your home screen for quick access +

+
+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index b7d6c4d..360a4ae 100644 --- a/src/main.tsx +++ b/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( -); \ No newline at end of file +);