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 0000000..76b2a1b
Binary files /dev/null and b/puffin-calculator-integrated.zip differ