From 9053de0e5106d0f5c66d1e577ce67298d16a05a3 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 4 May 2025 19:58:46 +0200 Subject: [PATCH] Calc App --- puffin-calculator-iframe/Dockerfile | 16 + puffin-calculator-iframe/README.md | 111 ++ puffin-calculator-iframe/docker-compose.yml | 12 + puffin-calculator-iframe/iframe-example.html | 125 +++ puffin-calculator-iframe/index.html | 26 + puffin-calculator-iframe/js/app.js | 116 ++ puffin-calculator-iframe/js/components.js | 1039 ++++++++++++++++++ puffin-calculator-iframe/js/utils.js | 186 ++++ puffin-calculator-iframe/styles.css | 62 ++ puffin-calculator-integrated.zip | Bin 0 -> 8219 bytes 10 files changed, 1693 insertions(+) create mode 100644 puffin-calculator-iframe/Dockerfile create mode 100644 puffin-calculator-iframe/README.md create mode 100644 puffin-calculator-iframe/docker-compose.yml create mode 100644 puffin-calculator-iframe/iframe-example.html create mode 100644 puffin-calculator-iframe/index.html create mode 100644 puffin-calculator-iframe/js/app.js create mode 100644 puffin-calculator-iframe/js/components.js create mode 100644 puffin-calculator-iframe/js/utils.js create mode 100644 puffin-calculator-iframe/styles.css create mode 100644 puffin-calculator-integrated.zip diff --git a/puffin-calculator-iframe/Dockerfile b/puffin-calculator-iframe/Dockerfile new file mode 100644 index 0000000..cc95634 --- /dev/null +++ b/puffin-calculator-iframe/Dockerfile @@ -0,0 +1,16 @@ +FROM node:16-alpine as build + +# Set working directory +WORKDIR /app + +# Install a simple HTTP server for serving static content +RUN npm install -g http-server + +# Copy application files +COPY . . + +# Expose port +EXPOSE 8080 + +# Command to run the application +CMD ["http-server", ".", "-p", "8080", "--cors"] diff --git a/puffin-calculator-iframe/README.md b/puffin-calculator-iframe/README.md new file mode 100644 index 0000000..81882e1 --- /dev/null +++ b/puffin-calculator-iframe/README.md @@ -0,0 +1,111 @@ +# Puffin Carbon Offset Calculator (iFrame version) + +A lightweight, embeddable version of the Puffin Carbon Offset Calculator for yachts and marine vessels. + +## Features + +- Carbon emissions calculator based on fuel consumption or trip distance +- Support for multiple currencies +- Responsive design that works in any iFrame +- Automatic height adjustment +- Dark/light theme support +- Docker-ready for easy deployment + +## Getting Started + +### Running Locally + +1. Clone this repository +2. Open the project directory +3. Open `index.html` in your browser + +### Using Docker + +Build and run using Docker Compose: + +```bash +docker-compose up -d +``` + +This will start a server on port 8080. You can then access the calculator at http://localhost:8080. + +## Embedding in Your Website + +Add the following code to your website: + +```html + + + +``` + +See `iframe-example.html` for a complete integration example. + +## Advanced Usage + +The calculator exposes a JavaScript API that you can use to control it from the parent page: + +```javascript +// Access the iframe's window object +const calculatorFrame = document.getElementById('puffin-calculator-iframe'); + +// Set the theme (once the iframe has loaded) +calculatorFrame.addEventListener('load', function() { + // Access the API methods + calculatorFrame.contentWindow.PuffinCalculator.setTheme('dark'); + + // Reset the calculator to initial state + calculatorFrame.contentWindow.PuffinCalculator.resetCalculator(); +}); +``` + +## Project Structure + +``` +puffin-calculator-iframe/ +├── index.html # Main HTML file +├── iframe-example.html # Example of embedding the calculator +├── styles.css # Main stylesheet +├── Dockerfile # Docker configuration +├── docker-compose.yml # Docker Compose configuration +├── README.md # This file +└── js/ + ├── app.js # Main application code + ├── components.js # React components + └── utils.js # Utility functions +``` + +## Carbon Calculation Methods + +This calculator offers three methods for calculating carbon emissions: + +1. **Fuel-Based**: Calculate emissions based on the amount of fuel consumed (in liters or gallons) +2. **Distance-Based**: Calculate emissions based on distance traveled, vessel speed, and fuel consumption rate +3. **Custom Amount**: Enter a monetary amount directly for offsetting without a specific carbon calculation + +## Integration with WordPress + +To integrate with WordPress, see the companion PHP file `puffin-calculator-integrated.php` in the parent directory. + +## License + +Copyright (c) Puffin Offset. All rights reserved. diff --git a/puffin-calculator-iframe/docker-compose.yml b/puffin-calculator-iframe/docker-compose.yml new file mode 100644 index 0000000..3c4dfc9 --- /dev/null +++ b/puffin-calculator-iframe/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' + +services: + puffin-calculator: + build: . + container_name: puffin-calculator-iframe + ports: + - "8080:8080" + restart: unless-stopped + volumes: + - ./:/app + - /app/node_modules diff --git a/puffin-calculator-iframe/iframe-example.html b/puffin-calculator-iframe/iframe-example.html new file mode 100644 index 0000000..134f8b4 --- /dev/null +++ b/puffin-calculator-iframe/iframe-example.html @@ -0,0 +1,125 @@ + + + + + + Puffin Calculator iFrame Integration Example + + + +

Puffin Calculator iFrame Integration

+ +

This page demonstrates how to embed the Puffin Carbon Offset Calculator into your website using an iFrame.

+ +
+

Integration Code

+
+
<iframe 
+  src="http://localhost:8080"
+  id="puffin-calculator-iframe"
+  width="100%" 
+  height="600" 
+  frameborder="0" 
+  allow="clipboard-write"
+  title="Puffin Carbon Offset Calculator"
+></iframe>
+
+<script>
+  // Optional: Handle iframe resizing for responsive behavior
+  window.addEventListener('message', function(event) {
+    if (event.data.type === 'puffin-calculator-resize') {
+      const iframe = document.getElementById('puffin-calculator-iframe');
+      if (iframe) {
+        // Add a bit of extra space to avoid scrollbars
+        iframe.style.height = (event.data.height + 50) + 'px';
+      }
+    }
+  });
+</script>
+
+
+ +
+

Live Example

+

Here's the calculator embedded in this page:

+ + + + +
+ +
+

Advanced Usage

+

You can also communicate with the calculator through the iframe to set options or perform actions:

+ +
+
// Access the iframe's window object
+const calculatorFrame = document.getElementById('puffin-calculator-iframe');
+
+// Set the theme (once the iframe has loaded)
+calculatorFrame.addEventListener('load', function() {
+  // Access the API methods
+  calculatorFrame.contentWindow.PuffinCalculator.setTheme('dark');
+  
+  // Reset the calculator to initial state
+  calculatorFrame.contentWindow.PuffinCalculator.resetCalculator();
+});
+
+
+ + diff --git a/puffin-calculator-iframe/index.html b/puffin-calculator-iframe/index.html new file mode 100644 index 0000000..e63bcce --- /dev/null +++ b/puffin-calculator-iframe/index.html @@ -0,0 +1,26 @@ + + + + + + Puffin Carbon Offset Calculator + + + + +
+ + + + + + + + + + + + + + + diff --git a/puffin-calculator-iframe/js/app.js b/puffin-calculator-iframe/js/app.js new file mode 100644 index 0000000..4ea9978 --- /dev/null +++ b/puffin-calculator-iframe/js/app.js @@ -0,0 +1,116 @@ +// Main App Component +function App() { + const [showOffsetOrder, setShowOffsetOrder] = React.useState(false); + const [offsetTons, setOffsetTons] = React.useState(0); + const [monetaryAmount, setMonetaryAmount] = React.useState(undefined); + const [calculatorType, setCalculatorType] = React.useState('trip'); + + React.useEffect(() => { + // Log page view when component mounts + analytics.pageView('calculator'); + + // Make the app available globally for iframe communication + window.PuffinCalculator = { + setTheme: (theme) => { + document.body.className = theme === 'dark' ? 'dark bg-gray-900' : 'bg-gray-50'; + }, + resetCalculator: () => { + setShowOffsetOrder(false); + setOffsetTons(0); + setMonetaryAmount(undefined); + } + }; + + // Handle iframe resize + const resizeObserver = new ResizeObserver(entries => { + for (let entry of entries) { + // Notify parent if using postMessage API + if (window.parent !== window) { + window.parent.postMessage({ + type: 'puffin-calculator-resize', + height: entry.contentRect.height + }, '*'); + } + } + }); + + const rootElement = document.getElementById('root'); + if (rootElement) { + resizeObserver.observe(rootElement); + } + + return () => { + if (rootElement) { + resizeObserver.unobserve(rootElement); + } + }; + }, []); + + const handleOffsetClick = (tons, monetaryAmount) => { + setOffsetTons(tons); + setMonetaryAmount(monetaryAmount); + setShowOffsetOrder(true); + + // Scroll to top + window.scrollTo({ top: 0, behavior: 'smooth' }); + + // Track event + analytics.event('calculator', 'offset_click', `${tons} tons`); + }; + + // Render the appropriate component based on state + const renderContent = () => { + if (showOffsetOrder) { + return React.createElement(OffsetOrder, { + tons: offsetTons, + monetaryAmount: monetaryAmount, + onBack: () => { + setShowOffsetOrder(false); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + calculatorType: calculatorType + }); + } + + return React.createElement(TripCalculator, { + vesselData: sampleVessel, + onOffsetClick: handleOffsetClick + }); + }; + + return React.createElement('div', { + className: 'puffin-calculator min-h-[600px]' + }, [ + React.createElement('div', { + key: 'content', + className: 'px-4 sm:px-6 flex justify-center' + }, renderContent()) + ]); +} + +// Initialize the application when the DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + const rootElement = document.getElementById('root'); + + if (!rootElement) { + console.error('Root element not found. Cannot mount Puffin Calculator.'); + return; + } + + // Check for React and ReactDOM + if (!window.React || !window.ReactDOM) { + console.error('React or ReactDOM not loaded. Cannot mount Puffin Calculator.'); + return; + } + + // Initialize React app + const root = ReactDOM.createRoot(rootElement); + root.render(React.createElement(App, {})); + + // Notify when ready + if (window.parent !== window) { + window.parent.postMessage({ + type: 'puffin-calculator-ready' + }, '*'); + } +}); diff --git a/puffin-calculator-iframe/js/components.js b/puffin-calculator-iframe/js/components.js new file mode 100644 index 0000000..20f11f5 --- /dev/null +++ b/puffin-calculator-iframe/js/components.js @@ -0,0 +1,1039 @@ +// Icon Components +function Route() { + return React.createElement('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width: '24', + height: '24', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: '2', + strokeLinecap: 'round', + strokeLinejoin: 'round', + className: 'text-blue-500' + }, [ + React.createElement('path', { key: 'path1', d: 'M9 15a16 16 0 0 0 8 0' }), + React.createElement('path', { key: 'path2', d: 'M18 6a16 16 0 0 0-12 0' }), + React.createElement('circle', { key: 'circle1', cx: '12', cy: '3', r: '1' }), + React.createElement('circle', { key: 'circle2', cx: '19', cy: '6', r: '1' }), + React.createElement('circle', { key: 'circle3', cx: '5', cy: '6', r: '1' }), + React.createElement('circle', { key: 'circle4', cx: '12', cy: '21', r: '1' }), + React.createElement('circle', { key: 'circle5', cx: '19', cy: '18', r: '1' }), + React.createElement('circle', { key: 'circle6', cx: '5', cy: '18', r: '1' }), + React.createElement('path', { key: 'path3', d: 'M9 9a116 116 0 0 0 6 0' }), + React.createElement('path', { key: 'path4', d: 'M9 12a116 116 0 0 0 6 0' }) + ]); +} + +function ArrowLeft() { + return React.createElement('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width: '20', + height: '20', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: '2', + strokeLinecap: 'round', + strokeLinejoin: 'round', + className: 'mr-2' + }, [ + React.createElement('path', { key: 'path1', d: 'M19 12H5' }), + React.createElement('path', { key: 'path2', d: 'M12 19l-7-7 7-7' }) + ]); +} + +function CheckIcon() { + return React.createElement('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width: '32', + height: '32', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: '2', + strokeLinecap: 'round', + strokeLinejoin: 'round', + className: 'text-green-500' + }, [ + React.createElement('path', { key: 'path1', d: 'M20 6L9 17l-5-5' }) + ]); +} + +function LoaderIcon() { + return React.createElement('svg', { + xmlns: 'http://www.w3.org/2000/svg', + width: '20', + height: '20', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: '2', + strokeLinecap: 'round', + strokeLinejoin: 'round', + className: 'animate-spin mr-2' + }, [ + React.createElement('path', { key: 'path1', d: 'M21 12a9 9 0 1 1-6.219-8.56' }) + ]); +} + +// CurrencySelect Component +function CurrencySelect({ value, onChange }) { + return React.createElement('select', { + value: value, + onChange: (e) => onChange(e.target.value), + className: 'block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50 p-2' + }, + Object.entries(currencies).map(([code, currency]) => ( + React.createElement('option', { + key: code, + value: code + }, `${currency.symbol} ${code}`) + ))); +} + +// ProjectTypeIcon Component +function ProjectTypeIcon({ project }) { + // Safely check if project and type exist + if (!project || !project.type) { + return React.createElement('span', { className: 'text-blue-500' }, '🌎'); + } + + const type = project.type.toLowerCase(); + + switch (type) { + case 'direct air capture': + return React.createElement('span', { className: 'text-purple-500' }, '🏭'); + case 'blue carbon': + return React.createElement('span', { className: 'text-blue-500' }, '🌊'); + case 'renewable energy': + return React.createElement('span', { className: 'text-green-500' }, '💨'); + case 'forestry': + return React.createElement('span', { className: 'text-green-500' }, '🌲'); + default: + return React.createElement('span', { className: 'text-blue-500' }, '🌎'); + } +} + +// TripCalculator Component +function TripCalculator({ vesselData, onOffsetClick }) { + const [calculationType, setCalculationType] = React.useState('fuel'); + const [distance, setDistance] = React.useState(''); + const [speed, setSpeed] = React.useState('12'); + const [fuelRate, setFuelRate] = React.useState('100'); + const [fuelAmount, setFuelAmount] = React.useState(''); + const [fuelUnit, setFuelUnit] = React.useState('liters'); + const [tripEstimate, setTripEstimate] = React.useState(null); + const [currency, setCurrency] = React.useState('USD'); + const [offsetPercentage, setOffsetPercentage] = React.useState(100); + const [customPercentage, setCustomPercentage] = React.useState(''); + const [customAmount, setCustomAmount] = React.useState(''); + + const handleCalculate = React.useCallback((e) => { + 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 = React.useCallback((e) => { + const value = e.target.value; + if (value === '' || (Number(value) >= 0 && Number(value) <= 100)) { + setCustomPercentage(value); + if (value !== '') { + setOffsetPercentage(Number(value)); + } + } + }, []); + + const handlePresetPercentage = React.useCallback((percentage) => { + setOffsetPercentage(percentage); + setCustomPercentage(''); + }, []); + + const calculateOffsetAmount = React.useCallback((emissions, percentage) => { + return (emissions * percentage) / 100; + }, []); + + const handleCustomAmountChange = React.useCallback((e) => { + const value = e.target.value; + if (value === '' || Number(value) >= 0) { + setCustomAmount(value); + } + }, []); + + return React.createElement('div', { + className: 'bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full mt-8' + }, [ + // Header + React.createElement('div', { + key: 'header', + className: 'flex items-center justify-between mb-6' + }, [ + React.createElement('h2', { + key: 'title', + className: 'text-2xl font-bold text-gray-800' + }, 'Carbon Offset Calculator'), + React.createElement(Route, { key: 'icon' }) + ]), + + // Calculation Method + React.createElement('div', { + key: 'calc-method', + className: 'mb-6' + }, [ + React.createElement('label', { + key: 'method-label', + className: 'block text-sm font-medium text-gray-700 mb-2' + }, 'Calculation Method'), + React.createElement('div', { + key: 'method-buttons', + className: 'flex flex-wrap gap-3' + }, [ + React.createElement('button', { + key: 'fuel-btn', + type: 'button', + onClick: () => setCalculationType('fuel'), + className: `px-4 py-2 rounded-lg transition-colors ${ + calculationType === 'fuel' + ? 'bg-blue-500 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200' + }` + }, 'Fuel Based'), + React.createElement('button', { + key: 'distance-btn', + type: 'button', + onClick: () => setCalculationType('distance'), + className: `px-4 py-2 rounded-lg transition-colors ${ + calculationType === 'distance' + ? 'bg-blue-500 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200' + }` + }, 'Distance Based'), + React.createElement('button', { + key: 'custom-btn', + type: 'button', + onClick: () => setCalculationType('custom'), + className: `px-4 py-2 rounded-lg transition-colors ${ + calculationType === 'custom' + ? 'bg-blue-500 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200' + }` + }, 'Custom Amount') + ]) + ]), + + // Custom Amount Form + calculationType === 'custom' ? React.createElement('div', { + key: 'custom-form', + className: 'space-y-4' + }, [ + // Currency Select + React.createElement('div', { + key: 'currency-select', + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-2' + }, 'Select Currency'), + React.createElement('div', { + className: 'max-w-xs' + }, [ + React.createElement(CurrencySelect, { + value: currency, + onChange: setCurrency + }) + ]) + ]), + + // Amount Input + React.createElement('div', { + key: 'amount-input', + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-2' + }, 'Enter Amount to Offset'), + React.createElement('div', { + className: 'relative rounded-md shadow-sm' + }, [ + React.createElement('div', { + className: 'pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3' + }, [ + React.createElement('span', { + className: 'text-gray-500 sm:text-sm' + }, currencies[currency].symbol) + ]), + React.createElement('input', { + type: 'number', + value: customAmount, + onChange: handleCustomAmountChange, + placeholder: 'Enter amount', + min: '0', + className: 'mt-1 block w-full rounded-md border-gray-300 pl-7 pr-12 focus:border-blue-500 focus:ring-blue-500 sm:text-sm p-2', + required: true + }), + React.createElement('div', { + className: 'pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3' + }, [ + React.createElement('span', { + className: 'text-gray-500 sm:text-sm' + }, currency) + ]) + ]) + ]), + + // Offset Button + customAmount && Number(customAmount) > 0 ? React.createElement('button', { + key: 'offset-btn', + onClick: () => onOffsetClick(0, Number(customAmount)), + className: 'w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors mt-6' + }, 'Offset Your Impact') : null + ]) : + // Trip Calculator Form + React.createElement('form', { + key: 'trip-form', + onSubmit: handleCalculate, + className: 'space-y-4' + }, [ + // Fuel Based Form + calculationType === 'fuel' ? React.createElement('div', { + key: 'fuel-form' + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700' + }, 'Fuel Consumption'), + React.createElement('div', { + className: 'flex space-x-4' + }, [ + React.createElement('div', { + className: 'flex-1' + }, [ + React.createElement('input', { + type: 'number', + min: '1', + value: fuelAmount, + onChange: (e) => setFuelAmount(e.target.value), + placeholder: 'Enter amount', + className: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2', + required: true + }) + ]), + React.createElement('div', {}, [ + React.createElement('select', { + value: fuelUnit, + onChange: (e) => setFuelUnit(e.target.value), + className: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2' + }, [ + React.createElement('option', { + key: 'liters', + value: 'liters' + }, 'Liters'), + React.createElement('option', { + key: 'gallons', + value: 'gallons' + }, 'Gallons') + ]) + ]) + ]) + ]) : null, + + // Distance Based Form + calculationType === 'distance' ? React.createElement(React.Fragment, { + key: 'distance-form' + }, [ + React.createElement('div', { + key: 'distance-input' + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700' + }, 'Distance (nautical miles)'), + React.createElement('input', { + type: 'number', + min: '1', + value: distance, + onChange: (e) => setDistance(e.target.value), + className: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2', + required: true + }) + ]), + + React.createElement('div', { + key: 'speed-input' + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700' + }, 'Average Speed (knots)'), + React.createElement('input', { + type: 'number', + min: '1', + max: '50', + value: speed, + onChange: (e) => setSpeed(e.target.value), + className: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2', + required: true + }) + ]), + + React.createElement('div', { + key: 'fuel-rate-input' + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700' + }, 'Fuel Consumption Rate (liters per hour)'), + React.createElement('input', { + type: 'number', + min: '1', + step: '1', + value: fuelRate, + onChange: (e) => setFuelRate(e.target.value), + className: 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 p-2', + required: true + }), + React.createElement('p', { + className: 'mt-1 text-sm text-gray-500' + }, 'Typical range: 50 - 500 liters per hour for most yachts') + ]) + ]) : null, + + // Common Form Elements + React.createElement('div', { + key: 'currency-select' + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700' + }, 'Select Currency'), + React.createElement('div', { + className: 'max-w-xs' + }, [ + React.createElement(CurrencySelect, { + value: currency, + onChange: setCurrency + }) + ]) + ]), + + React.createElement('button', { + key: 'calculate-btn', + type: 'submit', + className: 'w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors' + }, 'Calculate Impact') + ]), + + // Results Section + tripEstimate && calculationType !== 'custom' ? React.createElement('div', { + key: 'results', + className: 'mt-6 space-y-6' + }, [ + // Trip Details + React.createElement('div', { + key: 'trip-details', + className: 'grid grid-cols-2 gap-4' + }, [ + // Trip Duration (only for distance calculation) + calculationType === 'distance' ? React.createElement('div', { + key: 'duration', + className: 'bg-gray-50 p-4 rounded-lg' + }, [ + React.createElement('p', { + className: 'text-sm text-gray-600' + }, 'Trip Duration'), + React.createElement('p', { + className: 'text-xl font-bold text-gray-900' + }, `${tripEstimate.duration.toFixed(1)} hours`) + ]) : null, + + // Fuel Consumption + React.createElement('div', { + key: 'fuel', + className: 'bg-gray-50 p-4 rounded-lg' + }, [ + React.createElement('p', { + className: 'text-sm text-gray-600' + }, 'Fuel Consumption'), + React.createElement('p', { + className: 'text-xl font-bold text-gray-900' + }, `${tripEstimate.fuelConsumption.toLocaleString()} ${fuelUnit}`) + ]) + ]), + + // Offset Percentage Selection + React.createElement('div', { + key: 'offset-percent', + }, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-2' + }, 'Offset Percentage'), + React.createElement('div', { + className: 'flex flex-wrap gap-3 mb-3' + }, [ + // Preset percentage buttons + [100, 75, 50, 25].map((percent) => ( + React.createElement('button', { + key: `percent-${percent}`, + type: 'button', + onClick: () => handlePresetPercentage(percent), + className: `px-4 py-2 rounded-lg transition-colors ${ + offsetPercentage === percent && customPercentage === '' + ? 'bg-blue-500 text-white' + : 'bg-gray-100 text-gray-700 hover:bg-gray-200' + }` + }, `${percent}%`) + )), + + // Custom percentage input + React.createElement('div', { + className: 'flex items-center space-x-2' + }, [ + React.createElement('input', { + type: 'number', + value: customPercentage, + onChange: handleCustomPercentageChange, + placeholder: 'Custom %', + min: '0', + max: '100', + className: 'w-24 px-3 py-2 border rounded-lg focus:ring-blue-500 focus:border-blue-500' + }), + React.createElement('span', { + className: 'text-gray-600' + }, '%') + ]) + ]) + ]), + + // Selected CO₂ Offset + React.createElement('div', { + key: 'co2-offset', + className: 'bg-blue-50 p-4 rounded-lg' + }, [ + React.createElement('p', { + className: 'text-sm text-gray-600' + }, 'Selected CO₂ Offset'), + React.createElement('p', { + className: 'text-2xl font-bold text-blue-900' + }, `${calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons`), + React.createElement('p', { + className: 'text-sm text-blue-600 mt-1' + }, `${offsetPercentage}% of ${tripEstimate.co2Emissions.toFixed(2)} tons`) + ]), + + // Offset Button + React.createElement('button', { + key: 'offset-btn', + onClick: () => onOffsetClick(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage)), + className: 'w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors' + }, 'Offset Your Impact') + ]) : null + ]); +} + +// OffsetOrder Component +function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType = 'trip' }) { + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const [success, setSuccess] = React.useState(false); + const [order, setOrder] = React.useState(null); + const [currency, setCurrency] = React.useState('USD'); + const [portfolio, setPortfolio] = React.useState(null); + const [loadingPortfolio, setLoadingPortfolio] = React.useState(true); + const [formData, setFormData] = React.useState({ + name: '', + email: '', + phone: '', + company: '', + message: `I would like to offset ${tons.toFixed(2)} tons of CO2 from my yacht's ${calculatorType} emissions.` + }); + + React.useEffect(() => { + fetchPortfolio(); + }, []); + + const fetchPortfolio = async () => { + try { + const portfolios = await getPortfolios(); + const puffinPortfolio = portfolios.find(p => + p.name.toLowerCase().includes('puffin') || + p.name.toLowerCase().includes('maritime') + ); + + if (!puffinPortfolio) { + throw new Error('Portfolio not found'); + } + + setPortfolio(puffinPortfolio); + } catch (err) { + setError('Failed to fetch portfolio information. Please try again.'); + } finally { + setLoadingPortfolio(false); + } + }; + + const handleOffsetOrder = async () => { + if (!portfolio) return; + + setLoading(true); + setError(null); + + try { + const newOrder = await createOffsetOrder(portfolio.id, tons); + setOrder(newOrder); + setSuccess(true); + } catch (err) { + setError('Failed to create offset order. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleFormSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + try { + await sendFormSubmission(formData); + setSuccess(true); + } catch (err) { + setError('Failed to send request. Please try again.'); + } finally { + setLoading(false); + } + }; + + const renderPortfolioPrice = (portfolio) => { + try { + // Get the price per ton from the portfolio + const pricePerTon = portfolio.pricePerTon || 200; // Default to 200 if not set + const targetCurrency = getCurrencyByCode(currency); + return formatCurrency(pricePerTon, targetCurrency); + } catch (err) { + console.error('Error formatting portfolio price:', err); + return formatCurrency(200, currencies.USD); // Default fallback + } + }; + + // Calculate offset cost using the portfolio price + const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 200) : 0); + + if (success && order) { + return React.createElement('div', { + className: 'bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full' + }, [ + React.createElement('div', { + key: 'success-icon', + className: 'text-center py-8' + }, [ + React.createElement('div', { + className: 'inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-6' + }, [ + React.createElement(CheckIcon, {}) + ]), + React.createElement('h3', { + className: 'text-2xl font-bold text-gray-900 mb-4' + }, 'Offset Order Successful!'), + React.createElement('p', { + className: 'text-gray-600 mb-6' + }, 'Your order has been processed successfully. You\'ll receive a confirmation email shortly.'), + React.createElement('div', { + className: 'bg-gray-50 rounded-lg p-6 mb-6' + }, [ + React.createElement('h4', { + className: 'text-lg font-semibold text-gray-900 mb-4' + }, 'Order Summary'), + React.createElement('div', { + className: 'space-y-2' + }, [ + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-600' + }, 'Order ID:'), + React.createElement('span', { + className: 'font-medium' + }, order.id) + ]), + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-600' + }, 'Amount:'), + React.createElement('span', { + className: 'font-medium' + }, formatCurrency(order.amountCharged / 100, currencies[order.currency])) + ]), + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-600' + }, 'CO₂ Offset:'), + React.createElement('span', { + className: 'font-medium' + }, `${order.tons} tons`) + ]), + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-600' + }, 'Portfolio:'), + React.createElement('span', { + className: 'font-medium' + }, order.portfolio.name) + ]) + ]) + ]), + React.createElement('button', { + onClick: onBack, + className: 'bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors' + }, 'Back to Calculator') + ]) + ]); + } + + if (success) { + return React.createElement('div', { + className: 'bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full' + }, [ + React.createElement('div', { + key: 'success-icon', + className: 'text-center py-8' + }, [ + React.createElement('div', { + className: 'inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-6' + }, [ + React.createElement(CheckIcon, {}) + ]), + React.createElement('h3', { + className: 'text-2xl font-bold text-gray-900 mb-4' + }, 'Request Submitted Successfully!'), + React.createElement('p', { + className: 'text-gray-600 mb-6' + }, 'Your offset request has been submitted. Our team will contact you shortly to complete the process.'), + React.createElement('button', { + onClick: onBack, + className: 'bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors' + }, 'Back to Calculator') + ]) + ]); + } + + return React.createElement('div', { + className: 'bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full' + }, [ + React.createElement('button', { + key: 'back-button', + onClick: onBack, + className: 'flex items-center text-gray-600 hover:text-gray-900 mb-6' + }, [ + React.createElement(ArrowLeft, {}), + 'Back to Calculator' + ]), + + React.createElement('div', { + key: 'header', + className: 'text-center mb-8' + }, [ + React.createElement('h2', { + className: 'text-3xl font-bold text-gray-900 mb-4' + }, 'Offset Your Impact'), + React.createElement('p', { + className: 'text-lg text-gray-600' + }, `You're about to offset ${tons.toFixed(2)} tons of CO₂`) + ]), + + error ? React.createElement('div', { + key: 'error', + className: 'bg-red-50 border border-red-200 rounded-lg p-4 mb-6' + }, [ + React.createElement('div', { + className: 'flex items-center space-x-2' + }, [ + React.createElement('span', { + className: 'text-red-500' + }, '⚠️'), + React.createElement('p', { + className: 'text-red-700' + }, error) + ]) + ]) : null, + + loadingPortfolio ? React.createElement('div', { + key: 'loading', + className: 'flex justify-center items-center py-12' + }, [ + React.createElement(LoaderIcon, {}), + React.createElement('span', { + className: 'ml-2 text-gray-600' + }, 'Loading portfolio information...') + ]) : portfolio ? React.createElement(React.Fragment, { + key: 'portfolio' + }, [ + // Portfolio description + React.createElement('div', { + className: 'bg-white border rounded-lg p-6 mb-8' + }, [ + React.createElement('h3', { + className: 'text-xl font-semibold text-gray-900 mb-4' + }, portfolio.name), + React.createElement('p', { + className: 'text-gray-600 mb-6' + }, portfolio.description), + + // Projects + portfolio.projects && portfolio.projects.length > 0 ? + React.createElement('div', { + className: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6' + }, portfolio.projects.map((project) => + React.createElement('div', { + key: project.id, + className: 'bg-gray-50 rounded-lg p-4 hover:shadow-md transition-shadow' + }, [ + React.createElement('div', { + className: 'flex items-center space-x-2 mb-3' + }, [ + React.createElement(ProjectTypeIcon, { project }), + React.createElement('h4', { + className: 'font-semibold text-gray-900' + }, project.name) + ]), + project.imageUrl ? React.createElement('div', { + className: 'relative h-32 mb-3 rounded-lg overflow-hidden' + }, [ + React.createElement('img', { + src: project.imageUrl, + alt: project.name, + className: 'absolute inset-0 w-full h-full object-cover' + }) + ]) : null, + React.createElement('p', { + className: 'text-sm text-gray-600 mb-3' + }, project.shortDescription || project.description), + React.createElement('div', { + className: 'space-y-1 text-sm' + }, [ + project.location ? React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-500' + }, 'Location:'), + React.createElement('span', { + className: 'text-gray-900' + }, project.location) + ]) : null, + project.type ? React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-500' + }, 'Type:'), + React.createElement('span', { + className: 'text-gray-900' + }, project.type) + ]) : null, + project.verificationStandard ? React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-500' + }, 'Standard:'), + React.createElement('span', { + className: 'text-gray-900' + }, project.verificationStandard) + ]) : null, + project.impactMetrics?.co2Reduced ? React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-500' + }, 'Impact:'), + React.createElement('span', { + className: 'text-gray-900' + }, `${project.impactMetrics.co2Reduced.toLocaleString()} tons CO₂`) + ]) : null + ]) + ]) + )) : null, + + // Price per ton + React.createElement('div', { + className: 'flex items-center justify-between bg-blue-50 p-4 rounded-lg' + }, [ + React.createElement('span', { + className: 'text-blue-900 font-medium' + }, 'Portfolio Price per Ton:'), + React.createElement('span', { + className: 'text-blue-900 font-bold text-lg' + }, renderPortfolioPrice(portfolio)) + ]) + ]), + + // Order summary + React.createElement('div', { + className: 'bg-gray-50 rounded-lg p-6 mb-6' + }, [ + React.createElement('h3', { + className: 'text-lg font-semibold text-gray-900 mb-4' + }, 'Order Summary'), + React.createElement('div', { + className: 'space-y-4' + }, [ + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-600' + }, 'Amount to Offset:'), + React.createElement('span', { + className: 'font-medium' + }, `${tons.toFixed(2)} tons CO₂`) + ]), + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-600' + }, 'Portfolio Distribution:'), + React.createElement('span', { + className: 'font-medium' + }, 'Automatically optimized') + ]), + React.createElement('div', { + className: 'border-t pt-4' + }, [ + React.createElement('div', { + className: 'flex justify-between' + }, [ + React.createElement('span', { + className: 'text-gray-900 font-semibold' + }, 'Total Cost:'), + React.createElement('span', { + className: 'text-gray-900 font-semibold' + }, formatCurrency(offsetCost, getCurrencyByCode(portfolio.currency))) + ]) + ]) + ]) + ]), + + // Submit button + React.createElement('button', { + onClick: handleOffsetOrder, + disabled: loading, + className: `w-full bg-blue-500 text-white py-3 px-4 rounded-lg transition-colors ${ + loading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-600' + }` + }, loading ? + React.createElement('div', { + className: 'flex items-center justify-center' + }, [ + React.createElement(LoaderIcon, {}), + 'Processing...' + ]) : + 'Confirm Offset Order' + ) + ]) : React.createElement('div', { + key: 'contact-form', + className: 'max-w-2xl mx-auto' + }, [ + React.createElement('div', { + className: 'bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8' + }, [ + React.createElement('h3', { + className: 'text-xl font-semibold text-blue-900 mb-4' + }, 'Contact Us for Offsetting'), + React.createElement('p', { + className: 'text-blue-700 mb-4' + }, 'Our automated offsetting service is temporarily unavailable. Please fill out the form below and our team will help you offset your emissions.'), + React.createElement('form', { + onSubmit: handleFormSubmit, + className: 'space-y-6' + }, [ + React.createElement('div', {}, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-1' + }, 'Name *'), + React.createElement('input', { + type: 'text', + required: true, + value: formData.name, + onChange: (e) => setFormData(prev => ({ ...prev, name: e.target.value })), + className: 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500' + }) + ]), + React.createElement('div', {}, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-1' + }, 'Email *'), + React.createElement('input', { + type: 'email', + required: true, + value: formData.email, + onChange: (e) => setFormData(prev => ({ ...prev, email: e.target.value })), + className: 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500' + }) + ]), + React.createElement('div', {}, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-1' + }, 'Phone'), + React.createElement('input', { + type: 'tel', + value: formData.phone, + onChange: (e) => setFormData(prev => ({ ...prev, phone: e.target.value })), + className: 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500' + }) + ]), + React.createElement('div', {}, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-1' + }, 'Company'), + React.createElement('input', { + type: 'text', + value: formData.company, + onChange: (e) => setFormData(prev => ({ ...prev, company: e.target.value })), + className: 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500' + }) + ]), + React.createElement('div', {}, [ + React.createElement('label', { + className: 'block text-sm font-medium text-gray-700 mb-1' + }, 'Message'), + React.createElement('textarea', { + rows: 4, + value: formData.message, + onChange: (e) => setFormData(prev => ({ ...prev, message: e.target.value })), + className: 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500' + }) + ]), + React.createElement('button', { + type: 'submit', + disabled: loading, + className: `w-full flex items-center justify-center bg-blue-500 text-white py-3 rounded-lg transition-colors ${ + loading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-600' + }` + }, loading ? + React.createElement(React.Fragment, {}, [ + React.createElement(LoaderIcon, {}), + 'Sending Request...' + ]) : + 'Send Offset Request' + ) + ]) + ]) + ]) + ]); +} diff --git a/puffin-calculator-iframe/js/utils.js b/puffin-calculator-iframe/js/utils.js new file mode 100644 index 0000000..41602d5 --- /dev/null +++ b/puffin-calculator-iframe/js/utils.js @@ -0,0 +1,186 @@ +// Constants for carbon calculations +const EMISSION_FACTOR = 3.206; // tons of CO₂ per ton of fuel +const FUEL_DENSITY = 0.85; // tons per m³ (or metric tons per kiloliter) +const GALLONS_TO_LITERS = 3.78541; // 1 US gallon = 3.78541 liters +const LITERS_TO_CUBIC_METERS = 0.001; // 1 liter = 0.001 m³ + +// Available currencies +const currencies = { + USD: { code: 'USD', symbol: '$', rate: 1 }, + EUR: { code: 'EUR', symbol: '€', rate: 0.92 }, + GBP: { code: 'GBP', symbol: '£', rate: 0.79 }, + CHF: { code: 'CHF', symbol: 'CHF', rate: 0.88 }, +}; + +// Format currency +function formatCurrency(amountUSD, currency) { + if (!currency) { + currency = currencies.USD; + } + + // Convert USD amount to target currency + const convertedAmount = amountUSD * currency.rate; + + return `${currency.symbol}${convertedAmount.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}`; +} + +// Get currency by code +function getCurrencyByCode(code) { + return currencies[code] || currencies.USD; +} + +// Calculate trip carbon +function calculateTripCarbon(vesselData, distance, speed, fuelRateLitersPerHour) { + const tripHours = distance / speed; + + // Calculate total fuel consumption in liters + const fuelConsumptionLiters = fuelRateLitersPerHour * tripHours; + + // Convert liters to tons for CO₂ calculation + const fuelConsumptionTons = (fuelConsumptionLiters * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY; + + // Calculate CO₂ emissions + const fuelRateTonsPerHour = (fuelRateLitersPerHour * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY; + const emissionsPerNM = (fuelRateTonsPerHour * EMISSION_FACTOR) / speed; + const totalEmissions = emissionsPerNM * distance; + + return { + distance, + duration: tripHours, + fuelConsumption: Math.round(fuelConsumptionLiters), + co2Emissions: Number(totalEmissions.toFixed(2)) + }; +} + +// Calculate carbon from fuel +function calculateCarbonFromFuel(fuelAmount, isGallons = false) { + // Convert to liters if input is in gallons + const liters = isGallons ? fuelAmount * GALLONS_TO_LITERS : fuelAmount; + + // Convert liters to cubic meters (m³) + const cubicMeters = liters * LITERS_TO_CUBIC_METERS; + + // Convert volume to mass (tons) + const fuelTons = cubicMeters * FUEL_DENSITY; + + // Calculate CO₂ emissions + const co2Emissions = fuelTons * EMISSION_FACTOR; + + return Number(co2Emissions.toFixed(2)); +} + +// Sample vessel data +const sampleVessel = { + imo: '1234567', + vesselName: 'Sample Yacht', + type: 'Yacht', + length: 50, + width: 9, + estimatedEnginePower: 2250 +}; + +// Default portfolio for fallback +const DEFAULT_PORTFOLIO = { + id: 1, + name: "Puffin Maritime Portfolio", + description: "A curated selection of high-impact carbon removal projects focused on maritime sustainability.", + projects: [ + { + id: "dac-1", + name: "Direct Air Capture", + description: "Permanent carbon removal through direct air capture technology", + shortDescription: "Direct air capture in Iceland", + imageUrl: "https://images.unsplash.com/photo-1553547274-0df401ae03c9", + pricePerTon: 200, + location: "Iceland", + type: "Direct Air Capture", + verificationStandard: "Verified Carbon Standard", + impactMetrics: { + co2Reduced: 1000 + } + }, + { + id: "ocean-1", + name: "Ocean Carbon Removal", + description: "Enhanced ocean carbon capture through marine permaculture", + shortDescription: "Marine carbon capture", + imageUrl: "https://images.unsplash.com/photo-1498623116890-37e912163d5d", + pricePerTon: 200, + location: "Global Oceans", + type: "Ocean-Based", + verificationStandard: "Gold Standard", + impactMetrics: { + co2Reduced: 5000 + } + } + ], + pricePerTon: 200, + currency: 'USD' +}; + +// Simplified analytics +const analytics = { + pageView: function(path) { + console.log(`Analytics pageView: ${path}`); + }, + event: function(category, action, label) { + console.log(`Analytics event: ${category} - ${action} - ${label}`); + }, + error: function(error, message) { + console.error(`Analytics error: ${message}`, error); + } +}; + +// Create portfolio API call (with fallback to default) +async function getPortfolios() { + try { + // In a real implementation, this would be an API call + // For demo purposes, we'll just return the default portfolio after a small delay + return new Promise((resolve) => { + setTimeout(() => resolve([DEFAULT_PORTFOLIO]), 1000); + }); + } catch (error) { + console.warn('Failed to fetch portfolios from API, using fallback'); + return [DEFAULT_PORTFOLIO]; + } +} + +// Create offset order API call (simulated) +async function createOffsetOrder(portfolioId, tons, dryRun = false) { + try { + // Simulate API call + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + id: `order-${Math.floor(Math.random() * 1000000)}`, + amountCharged: tons * 200 * 100, // in cents + currency: 'USD', + tons: tons, + portfolio: DEFAULT_PORTFOLIO, + status: 'completed', + createdAt: new Date().toISOString(), + dryRun: dryRun + }); + }, 1500); + }); + } catch (error) { + console.error('Failed to create offset order', error); + throw new Error('Failed to create offset order. Please try again.'); + } +} + +// Email validation +function validateEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + +// Send form submission (simulated) +async function sendFormSubmission(data) { + return new Promise((resolve) => { + setTimeout(() => resolve({ success: true }), 1000); + }); +} diff --git a/puffin-calculator-iframe/styles.css b/puffin-calculator-iframe/styles.css new file mode 100644 index 0000000..2a0624b --- /dev/null +++ b/puffin-calculator-iframe/styles.css @@ -0,0 +1,62 @@ +/* Puffin Calculator Styles */ + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; +} + +.puffin-calculator { + max-width: 100%; + margin: 0 auto; +} + +/* Custom focus styles for better accessibility */ +input:focus, select:focus, button:focus { + outline: 3px solid rgba(59, 130, 246, 0.5); + outline-offset: 2px; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #94a3b8; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +/* Safari input number button styling */ +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + opacity: 1; + height: 24px; +} + +/* Responsive adjustments */ +@media (max-width: 640px) { + .puffin-calculator { + padding: 1rem; + } +} + +/* Animation for loading state */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} diff --git a/puffin-calculator-integrated.zip b/puffin-calculator-integrated.zip new file mode 100644 index 0000000000000000000000000000000000000000..76b2a1b569ca5c75c117b032eb7f84525011c706 GIT binary patch literal 8219 zcmb8URZtuZ53q}~Xp6hMyYAxdPK(RpvJ`D84#nNw-EEQLZpERvyK8a#-|t+U^WUCi zA~Tsxa+8}p>PqkkxG;eKbR9lRALjoON*GF*Z|+uBwh%V3nFH9}!OYFch0PY?W@+tW z=4NTZ{>|o_`wui2ScLyI{Xb4e8v_QJx(?Gu`ag@R#k4sWQE}aEPI%DX7o#*j40q9M zI2-*zKlX#3+>dr6eg^DAgnm!{tPo!!fpl+h~QMbncpv z+&Fd^D|$5GII1Lu=!NQ!r+lG?4RZatm(L?U<{PcGgIB61ggfNJk54X9&>YdP#B2c7 z@w?YLxmS}|G=uNgvd`TPK2L~S%`&P|KV>@8zIw>ZadFAf5X16%DM!_^qFR<&Lho~P zvoCT#mh4(kI_q~Y>&`~F3gvsqECexBJJO6ptd!YqU~jSHvyjsGN5ZX#n~#_LN@;V{ zu{j%POfMx&t|X_ zVe;1p%d|w6)vVJXjD+_`QBJA3efLCno){XJls)<@!j*noR(mSK=2}BL6h@{Y|nIow4bSJp`8haEdZHjlGfi#(bX9Q7SErrQQLg zUarn~TD(*2_GNrtb$~2`*QRYeJG_;FtZlV*J<(8s+Q?_|Y0j66PsmulQ+R&&yf1G9 z4YdF*YA^fSJCjhW&(q&9+WZctJNaZcMLh$Blc#3}81iWA#eQ5yO#Wnf-Y$3}o4;FM znFGb^B{w&5RiZTCNFpOA!}Fb3})9Y5=N6hmR;QU|YWN zMW6eW4h6aS_gIpnX5upjpK}^|AT!Dz9l-t*V>(6xDmrf2#(~NDm}ic|MEwvB#ECXG zxIW@o(?ML-IR1HQBH;qZj$n^qM3hiy{vUcYmPAzWS1AaHb#c}bY?9MI>%9ZE-`IH! z_8}t8t7^8>P#IE-)np+N2}fD`l(cK?W(s2p zi4ZZ41fhm6e3$aX$x)?q>&^1}Uc15B+{0>YOG(WjMVrKtfmX=Z#iq^+lg4c_r09iv zFfYP3UYe@5Rc^yiV9KTLd~~ks-EJf*sWg~bLj(JA4VrDQ&4_GmZC6!a8Kuj56I~!L z>t*3-bd1AKDVK_jo~p^J3PL_J?!}axw4}@I2UN3V;;GXNj#o`;(JK>8xh#c*xQCO{ z>Ko19b)32^740Orovuv>yD@uwZj?#9u#OPCxMFj)ObBlJD+_T9*+-iieY2&88p>e_j*2oG z&)tZC|2g|Bg)yc|VT;0m-}?DZ-WS5F6Zv9lEGLQFA6;^pIDj5ksZKQyFA>Yr(1W8l z%Of?BN}d}sx9Sha*YhRPPnpntuT=dlHX_Yf&GstB`P3O5+>>Gi4vI)V(R0!P4AlKFTF7@yD> z_k~j+#iemU+ZsF3Y5t)WKk`&tDz|ra^tktT^l9|;i}jdc2HvZ3VVH2})~W0W>;fpN_i_5%5|Rc&^!CUto6B!oo11jC^0k57kdw1r zCifnl=n@F$m$7+F~n2=1v7q2ZI(|4 zH3EgCVTT&;qqFCdaIo~y)#{`(#CL{vCZJ1Rb+J8F z;kRXHhl&gW%2|R(E-4MTzN^T32T&4iIncqo+EH|Zgn;mPLV_D&f_`sEfcL!ixm6XS z+()p>)pLvAdt#n5tkV^u;#Z=c-P{;sbj(=e?Ch)$)ajq=!=HFe0sU`_A6W2lZcLbt z%P$YjyFAwpsR8e1AA@M>2SK-s@TS9s0@moOaoCz=~$Elj}hnzx` z2%cASw~0Z$e`sI{v$BohQUc^R%;0&wEI&}!L|p-f0y`1Cy0(H`tH}q9);~Y*ilP@7 z`ari(*0Q9vpbOgpmA@Qa4Ens}#5&_)dJ@sM{@d6IwP5khqZD`S)07$=;}xyc)8dD` zIJu7j>fY8c7zF?t6@LW7?O%iGa2>Y=@1;Js{_1Od0+mPeTM6N?LDJX>@3^KtdDskv z*+z7S?Oe?ixh2G`vp0hEanH@pwYzn@_uF9B>Ir`bU*T_k{u~4^UK!S-zFRc{t8Sm0 zxo4(yiW0QC+ti5C5+H?h_OG#%{|My@|EJy~$%@>_~1P2JBtAU?| zxtiTJOl5K+4COc{9J=9_nTSXeP>EP;cG_e+0huiVqS1r6(^MqYzFdI|dYSa~yq6bS z70+iT<_lH^P&{Zc?G6OuJ`lBjmh!%0jTdP`_SU1zO!>7zDB=x+RQxBK-tcdPQNjbE zIy6ySoKcE+E+HDdfxFSLXGzuYDar=Zgho`%q;&N3f~4DoW5^=507w5TA-H9lkTx8E}Q@JNwHyiRKM&Dx9wtG(U#?2 z+k6!AR`3eHCgvhBh!dq+C*MPpscIyljgzTXcQ5DG)Hvh1=#N#}Jm_x?Ov;13G1vFr zkvSCTdA*Mm8@RNZvx~EQXyrgrZK>FSSkh5y(q6RYE+&73E6H@?P-=j_zK|lVepox` zSQ=_K!H(q~=S+3ZX}(Hc<5hnrY$m9S8kxvgP<3EU+YdM_K2Vlg7*}HSNgxjLXA;;Lk(ZFhRt0mSx?YFJ3l0Succ0T5q4$9P|dC3I(yW`#T}TT z=2a@hyFlpaW9m@5mUx;WSJa{y$S%GGAUO zftgK%i58^?dxmdQ{~iFp*G)8y%9?uRlEfo`Tx*ik?5VK)5;1a0| z*|B0J%!nxO3VSj6$p0`Rvs3{sgDJ}Sn&_;tBQ|?nP)Qs z&XM<6n~B!9?iOL#X9*}FX2-hW3apIOgpP|9f$#{zim}u zBPkRo=019_X_x+Db1az@le8C^mn1)#eR(~-20uI$98b(~QN34@H3jE}l5g|Aj(GHL zfjDHd{jD@AG{IjHlRo;>pcJ_>8DKkI@`>j)nWLjq>kG&klkgY#S{-Kg^jKVBToyJ| zjNcwD@M*M>hw?EFxwpDXSiBx;*XKX_hf&C%yeyH}6eaVPU2Tsq6mJr5LHwGiF(8w- zPvbG6@UHEO8yWA&I8qPy1p<2@v2EGNzC~Hw1&TAa@vnfxJlS-yu~NJkRZ0vYq-T~T zU%~is)B&L7A6p4jh!MuU2?z7q(s3_VWXX)zsp8{K=je_T=mho9GG#xH_f@IHRS}b+ z$)r)+#EHefVq0bxF%yHC0jcqEl@)7j`CFIb|BaZuOWru; zo|*mAZr3HpZ8PyN+de{XtR=K+;tOAiDE!jJ=iZcZr8vl7QHa=1E&?X{YhH?coZ7bW zrOANVXvRk8BPn(mjgSAYFX^CL0Mpb~c%BpXn-y9u7mG5#OL0q_++_n(!^n-tT6gQD zQe~I=4gWma-ujhqa-!4n7d~?EnPSe3nIZ~%w9)Sa2> z&mp97w}y0QQ4&#-zHNAp#Po9nRb~mCwNJBZU6=EA$F%ERl~bVwp_n|L5Lu}49_Koh zJ-z65X;02FXog|DfGHuDDwF?l-px$9-u>;t17hu9+vKBSpM4iZ2 zsM=7_#ZAJ=cSEA|VWfXnfK^TVOM=(WxwuN@(!B7|VL+uQv<~U-ANBE2Q=8ogCOr!^ zeEd5lD-FC^{i2eg3JuY;3f#{h8}cY-eg4cf0jeEmwD}2c1O>xE$3WKmtH$_t8h(zZ zT!`FOBi93^?{TvrlQaXysNQd7{{HqbudPO(V6x^RQul9CK(5gE%Q^+-PXJV~My#M4 zX}{R*Tws9K>6w6&m93W9HwRN_#Y(0#$;y}bRRY%Kp02j_B>v^1cx(b{YI&($Q%yTO z9nDz@z)u`WfKt}w`Fzs=S8;>^YQk4(&r~)DFATn*DT#`AXm%&%UT$R5em_p=4G-Eo{!Ht&fjKQN#t|Z*&G<^nIM2^iOu@ zIKD$G)+N<;7k>cQr!-UP4<@^C3O^-Kk>gBSMzv;HT*ot%ZN6oOBbOelTHzkhx&$Qs zScyV4siwpY2mTV)P=mQ*LgV+OnlLD;0kPCa(j8cFi<{}NK}{uH72OtUTXjwbFqXpg z5kA3x!_Blya0}357IBFpN%)jleLtG)^T)_Wq?V+RvdJ5=3CzSa0$vbE{k!P@GG&!( zf7H*(nB8Jxw*Z$2{n}^q2)w7|%?8MNGNqi6uOahSIqSBSThO6gdfgH%32)vHleiMp zxpA>MoSW|;?Rd^eDL^{a$n7m61dfE1`A9M{VEYG<9qJ{DsN%U$ zFy$$E;r(|H(dq??&DL3{rLm{F5a_T!PbgeVVJp{~Xbpdk#A#*fv+z`%O# z1^o!_K5v?yc=!iyqMf#iP3u6}Gq8RIKeK0EBZgSs8&uun@!{RfZ1xcm(n=BRxIs7M zH5L1@b<)Gj$jZ7ZHP*E1jD1i=SHWWP+k5D z6`7@yUknVZ2V)qCMT|zRe=6I3shL+3xiTX72m4$q{khS~>4BM=;^XlTz26Y%q-cui z;5tTke{OxP4ei!^JbK9-GFNS1spsreHy2x?biQwyA=lfTKQrH($#Govd<1+Nr;KW2 zoLup7Z=t%T8m^pSpJ{DWN^rm5zmKHv1%%?(PCf(r;UZ<~emJTK-Eu?O0{!Ajskfxo zboE*Ut4fbFg~NEhgl&6GMq%t9>rG(O*pwv((|ZwQsNgZ45<3N}y!CyCyH&$K{tHxB z`s#WcvIgOiQoT6dCoQlv&}i6wU!_0yYL_H8~aWV1+gT$f6Vr?>_cY(8pmIG`)0UD;<-Atvg^f#@B> z2@HDZ!dp%S%;;_TU>l!QSQN@?A!W;#yb=5@PRhcyE%kvKXA~?s*1xqmt4z~s3D9sL z(2_q9^-ZQA)1TpaBd3#-0)l*Z{-@GiuT+(?f4jK%H5C$n4AY}}?*q&JvH6Ao2}Mm# zLk_APhLf`F9KjG2EhR&-zKQ18y0q;}x5v1pM_y>sV#ZT~($@tH!`k*MipJtt$re_K zB*z@wAXP{e{6CPKbAG_dqc+W<(ik$G(`34m<1uQ6fyeT4yBC%0Z1!;ni2avcV*XOa zYVEb|Y@n8r%eCt437+tXumPi3r%(K!+lA8lV3*$YL>(pJWFSj&=*GzgQ6HD*1Ug(c znpDy25c1Gy=0LDyJ<((W{HV|6Z=s*v3Jovk%OJmVL~8PXsO=iC?WN1b=<|Gzp~)hB zCMr&Dt&yiJXJJlQ(Y+DoajHcGLN>1ztv_uFv7!rbalP!&LqqZ@T>7>+RQj|7_EPwq zh>df8w4`d{9$A?JR8)Ky>LP}c1OPbQ(owZF7r8l zLPUkq?)ki~sh0fu;Q=!Gy}YC<<6+H@r0j0;+*$KkbS7_2@^^}p%(&*>daG)Acg++j zCW{4a7FDEbFVNRd~H)c`rQas;S6X%g1|dJIcDj9@NhFz)S1hWOU~8l_?QD+COqZ2tD_5<_e63 zI}QW7x<&$VcJ_8X+tt%_@QLJuR16=3PK6@X>8=AKX{XgOJ?%BXCHF+0!$-UcM+56@ zYu(l*_?a3|DV;;ec3?MAJe9HH=UJ?#+Ooesr2#S~roF@F+ZW{^;Ojv48pG?BE#>TKH$?B5Ak~KO?ICE0&K+Ai8PpeTJ`Gk*d2Yzw= zFmqN$h^wKC|MT#7p3QHk$TqAd!W#QFCnji-3U3}vnf7$|y64%u`and@xj#j>|Mde7OSLoT0zGWvpaJL;wq zqMCmQezVFWu92(#2NxE9d9#s-*jpyAeTcBEAbDPNh^dFqQU??v{Bx*|?NID#Wi6;N zN;9tQrjg%TUE=H;h%VHXr@I|#KjW|?J^m`0_H5gpAyjj{rE z`KGrcfW%*qhAI@Jklebe#|GQOS?vKb4Bo-nuWG#%Rh3Uy5yYA!X-Q4ZkjG@ zOIFxR3f`>zrD+W?ab4EpbKh!NRGe`ae{1U_H})G-cQCZ$wdBj>E2fMfSGpJ$)4Qv+ zyX7O+aA75weW+?3Y*MIzt;>_70Ij?mva3#BmK{po{*n^@w7|K;*pXk2xGB z1HIj3=(DXQFltywG zlbi&I37ZG%;%8y-SJjK>;6vg!6C$tX5mH;3mMtcf^n|kWbQZ(&@0b%y2rRP$R7ckL zB_2Puv4`I~e(6FVAQoGb3(iKXr^>=ky;T4R`wxp`+vV0zeD^>pZp`Zx)A})Zr-Y>< z83I?oBKoWK6Ws-Rbl*goCO0Y|Fbs2?vzgAmg*{~lT+_5FzS8h&SB59aMK|6}cRUJ0^Zt%-uYWxQ>hNcTd(g)S{}bdE7TV#dXRgglREOn|juC<_y^(2?SU zo9s})mSI4pN(j#l560811^dr{tcc(-?t8c4N=lu=lXs&r0f~NkT&bxo`5U4&JZVe3-9$|pTK~GO6`LnV zeRQh_WMJakE9PoTZb9?zpGrUHBD`2i@A z%F{kT#zY#&$0*$r-*Xu4k&f)%Oy$`-o<G73iZ$_x@ zTIjNrsZy}?=ky=J#Xw@5Pg<_Yc2CHF3{zEr*W}EWHMY`tq8O)3D5or{GPmhkc}|;$ z)rkLKCC1Q;nh9+*hu{(h(igSGrLnvOhrEB8$Jj}ahx2BUOk!VgEFfFm5>4YH0Xz16fiLVMqyyom0;m;;r~BC|9{sgVF>?UT;CkMx{gR>j} literal 0 HcmV?d00001