Add PWA support and implement mobile calculator component

This commit is contained in:
Matt 2025-06-05 01:08:00 +02:00
parent 4df64da3d4
commit 8cc4284140
7 changed files with 745 additions and 2 deletions

View File

@ -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
View 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
View 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);
}
})
);
})
);
});

View File

@ -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">

View 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>
);
}

View 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>
);
}

View File

@ -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>
);
);