Initial commit

This commit is contained in:
Matt 2025-04-25 01:09:44 +02:00
commit 58284e0d91
55 changed files with 10224 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

8
.bolt/prompt Normal file
View File

@ -0,0 +1,8 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

3
.env Normal file
View File

@ -0,0 +1,3 @@
VITE_WREN_API_TOKEN=35c025d9-5dbb-404b-85aa-19b09da0578d
VITE_FORMSPREE_CONTACT_ID=xkgovnby
VITE_FORMSPREE_OFFSET_ID=xvgzbory

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
VITE_WREN_API_TOKEN=your-token-here
VITE_FORMSPREE_CONTACT_ID=your-formspree-contact-form-id
VITE_FORMSPREE_OFFSET_ID=your-formspree-offset-form-id

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM node:16-alpine as build
# Set working directory
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install
# Copy source code
COPY . .
# Build the React app
RUN npm run build
# Production stage with Nginx
FROM nginx:alpine
# Copy built app from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

136
README.docker.md Normal file
View File

@ -0,0 +1,136 @@
# Puffin Offset Calculator Docker Setup
This guide explains how to containerize the Puffin Offset Calculator app and embed it into your WordPress website.
## Files Overview
- `Dockerfile` - Defines how to build the Docker image for the React app
- `nginx.conf` - Configures Nginx to serve the React app correctly
- `docker-compose.yml` - Orchestrates the container deployment
- `wordpress-integration.php` - WordPress plugin file for embedding the calculator
## Getting Started
### Prerequisites
- Docker and Docker Compose installed on your server
- WordPress website where you want to embed the calculator
- Basic knowledge of Docker and WordPress
### Step 1: Build and Run the Docker Container
1. Place the `Dockerfile`, `nginx.conf`, and `docker-compose.yml` files in the project root directory (where your React app code is located).
2. Create a `logs` directory for storing Nginx logs:
```bash
mkdir logs
```
3. Build and start the container:
```bash
docker-compose up -d
```
4. Verify the container is running:
```bash
docker ps
```
Your calculator should now be accessible at `http://localhost:8080`.
### Step 2: Configure for Production (Optional)
For production deployment, update the `docker-compose.yml` file:
1. Change the port if needed (e.g., if you have other services using port 8080)
2. Remove the development volume mounts
3. Set up any environment variables needed for your production environment
### Step 3: Install WordPress Plugin
1. Create a new directory in your WordPress plugins folder:
```
wp-content/plugins/puffin-offset-calculator/
```
2. Copy the `wordpress-integration.php` file to this directory.
3. Create the CSS and JS directories in the plugin folder:
```
wp-content/plugins/puffin-offset-calculator/css/
wp-content/plugins/puffin-offset-calculator/js/
```
4. Create empty placeholder files for CSS and JS:
```
touch wp-content/plugins/puffin-offset-calculator/css/puffin-calculator.css
touch wp-content/plugins/puffin-offset-calculator/js/puffin-calculator.js
```
5. Activate the plugin in your WordPress admin panel.
### Step 4: Embed the Calculator
You have two ways to embed the calculator:
#### Option 1: Using the Shortcode
Add the shortcode to any page or post:
```
[puffin_calculator]
```
You can customize the appearance:
```
[puffin_calculator height="600px" width="100%" url="http://your-server-ip:8080"]
```
#### Option 2: Using the Widget
1. Go to Appearance > Widgets in your WordPress admin.
2. Drag the "Puffin Offset Calculator" widget to your desired widget area.
3. Configure the widget settings (title, height, and URL).
## Troubleshooting
### Calculator Not Loading
- Check if the Docker container is running: `docker ps`
- Verify you can access the calculator directly at http://localhost:8080
- Check Nginx logs: `docker-compose logs puffin-calculator`
- Ensure your WordPress site can reach the Docker container (especially important if they're on different servers)
### CORS Issues
If you see CORS errors in your browser console, check that:
- The Nginx configuration is correctly allowing cross-origin requests
- Your WordPress site is using HTTPS but trying to embed an HTTP iframe (Mixed content blocking)
## Advanced Configuration
### Custom Domain
To use a custom domain for your calculator:
1. Update your DNS to point to your server
2. Modify nginx.conf:
```
server_name your-calculator-domain.com;
```
3. Set up SSL with Let's Encrypt
4. Update the URL in your WordPress shortcode or widget
### SSL Setup
For production, you should secure your calculator with SSL:
1. Use a reverse proxy like Traefik or Nginx Proxy Manager
2. Configure Let's Encrypt for automatic certificate generation
3. Update your docker-compose.yml to work with your chosen SSL solution
### Communication Between WordPress and the Calculator
For advanced integration where WordPress needs to exchange data with the calculator:
- Use postMessage API for iframe communication
- Implement message handling in both WordPress and the React app
- Add custom JS in the WordPress plugin's js file to facilitate this communication

66
README.md Normal file
View File

@ -0,0 +1,66 @@
# Wren API Tutorial (Node.js)
This example demonstrates how to use the Wren API to:
1. Fetch available offset projects
2. Create a carbon offset order
## Setup
1. Install dependencies:
```bash
npm install
```
2. Create a `.env` file with your Wren API token:
```
WREN_API_TOKEN=your-token-here
```
3. Run the example:
```bash
npm start
```
## API Documentation
For full API documentation, visit: https://wren.co/api
## Features
- Environment variable configuration
- Error handling
- Project listing
- Order creation
- Response parsing
## Sample Output
```
Fetching available offset projects...
Found 3 projects:
- Direct Air Carbon Capture (Iceland)
Price: $250/ton
Type: Direct Air Capture
- Coastal Mangrove Restoration (Indonesia)
Price: $150/ton
Type: Nature-Based
- Ocean Carbon Removal (Global Oceans)
Price: $200/ton
Type: Ocean-Based
Creating offset order for 1 ton of CO2...
Order created successfully!
------------------------
Order ID: ord_123abc
Amount: $200
Status: completed
Portfolio: Standard Portfolio
Portfolio projects:
- Direct Air Carbon Capture
- Coastal Mangrove Restoration
```

69
app.js Normal file
View File

@ -0,0 +1,69 @@
import dotenv from 'dotenv';
import axios from 'axios';
// Load environment variables
dotenv.config();
const WREN_API_TOKEN = process.env.WREN_API_TOKEN;
if (!WREN_API_TOKEN) {
console.error('Please set your WREN_API_TOKEN in .env file');
process.exit(1);
}
// Create API client
const api = axios.create({
baseURL: 'https://api.wren.co/v1',
headers: {
'Authorization': `Bearer ${WREN_API_TOKEN}`,
'Content-Type': 'application/json'
}
});
async function main() {
try {
// 1. Get available projects
console.log('Fetching available offset projects...');
const projectsResponse = await api.get('/projects');
const projects = projectsResponse.data.projects;
console.log(`Found ${projects.length} projects:\n`);
projects.forEach(project => {
console.log(`- ${project.name} (${project.location})`);
console.log(` Price: $${project.price_per_ton}/ton`);
console.log(` Type: ${project.type}\n`);
});
// 2. Create an offset order
console.log('Creating offset order for 1 ton of CO2...');
const orderResponse = await api.post('/orders', {
tons: 1,
currency: 'USD'
});
const order = orderResponse.data;
console.log('\nOrder created successfully!');
console.log('------------------------');
console.log(`Order ID: ${order.id}`);
console.log(`Amount: $${order.amount_charged/100}`);
console.log(`Status: ${order.status}`);
console.log(`Portfolio: ${order.portfolio.name}`);
// List projects in the portfolio
console.log('\nPortfolio projects:');
order.portfolio.projects.forEach(project => {
console.log(`- ${project.name}`);
});
} catch (error) {
if (error.response) {
console.error('API Error:', error.response.data);
} else {
console.error('Error:', error.message);
}
process.exit(1);
}
}
main();

23
docker-compose.yml Normal file
View File

@ -0,0 +1,23 @@
version: '3.8'
services:
puffin-calculator:
build: .
container_name: puffin-calculator
ports:
- "8080:80"
restart: unless-stopped
environment:
- NODE_ENV=production
volumes:
# For development only - remove these in production
# - ./src:/app/src
# - ./public:/app/public
# Persist Nginx logs
- ./logs:/var/log/nginx
networks:
- puffin-network
networks:
puffin-network:
driver: bridge

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

72
index.html Normal file
View File

@ -0,0 +1,72 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/puffin-logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>Puffin Offset - Carbon Offsetting for Superyachts</title>
<meta name="title" content="Puffin Offset - Carbon Offsetting for Superyachts">
<meta name="description" content="Luxury meets environmental responsibility. Calculate and offset your yacht's carbon footprint with verified projects. Join the sustainable yachting movement today.">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://puffinoffset.com/">
<meta property="og:title" content="Puffin Offset - Carbon Offsetting for Superyachts">
<meta property="og:description" content="Luxury meets environmental responsibility. Calculate and offset your yacht's carbon footprint with verified projects. Join the sustainable yachting movement today.">
<meta property="og:image" content="https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?auto=format&fit=crop&q=80">
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:url" content="https://puffinoffset.com/">
<meta property="twitter:title" content="Puffin Offset - Carbon Offsetting for Superyachts">
<meta property="twitter:description" content="Luxury meets environmental responsibility. Calculate and offset your yacht's carbon footprint with verified projects. Join the sustainable yachting movement today.">
<meta property="twitter:image" content="https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?auto=format&fit=crop&q=80">
<!-- Additional SEO Meta Tags -->
<meta name="keywords" content="yacht carbon offset, superyacht sustainability, maritime carbon calculator, eco-friendly yachting, carbon neutral yacht, sustainable luxury, yacht emissions">
<meta name="author" content="Puffin Offset">
<meta name="robots" content="index, follow">
<link rel="canonical" href="https://puffinoffset.com/">
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Puffin Offset",
"url": "https://puffinoffset.com",
"logo": "https://puffinoffset.com/puffin-logo.svg",
"description": "Luxury meets environmental responsibility. Calculate and offset your yacht's carbon footprint with verified projects.",
"contactPoint": {
"@type": "ContactPoint",
"telephone": "+33-6-71-18-72-53",
"contactType": "customer service",
"email": "info@puffinoffset.com"
},
"sameAs": [
"https://twitter.com/puffinoffset",
"https://linkedin.com/company/puffinoffset"
]
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Puffin Offset",
"url": "https://puffinoffset.com",
"potentialAction": {
"@type": "SearchAction",
"target": "https://puffinoffset.com/calculator?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

31
nginx.conf Normal file
View File

@ -0,0 +1,31 @@
server {
listen 80;
server_name localhost;
# Root directory and index file
root /usr/share/nginx/html;
index index.html;
# Handle SPA routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache control for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# Enable CORS for WordPress integration
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_comp_level 6;
gzip_min_length 1000;
}

6544
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "puffin-offset",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"axios": "^1.6.7",
"dotenv": "^8.2.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"jsdom": "^24.0.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2",
"vitest": "^1.3.1"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

20
public/puffin-logo.svg Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Head -->
<path d="M256 120C200 120 160 160 160 220C160 280 200 320 256 320C312 320 352 280 352 220C352 160 312 120 256 120Z" fill="#2B2B2B"/>
<!-- Body -->
<path d="M256 300C180 300 120 360 120 440H392C392 360 332 300 256 300Z" fill="#2B2B2B"/>
<!-- White chest patch -->
<path d="M256 280C220 280 200 320 200 380H312C312 320 292 280 256 280Z" fill="white"/>
<!-- Beak -->
<path d="M256 180C236 180 220 196 220 216V236C220 256 236 272 256 272C276 272 292 256 292 236V216C292 196 276 180 256 180Z" fill="#FF9800"/>
<!-- Eyes -->
<circle cx="220" cy="200" r="12" fill="white"/>
<circle cx="292" cy="200" r="12" fill="white"/>
<circle cx="220" cy="200" r="6" fill="#2B2B2B"/>
<circle cx="292" cy="200" r="6" fill="#2B2B2B"/>
</svg>

After

Width:  |  Height:  |  Size: 910 B

4
public/robots.txt Normal file
View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://puffinoffset.com/sitemap.xml

28
public/sitemap.xml Normal file
View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://puffinoffset.com/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://puffinoffset.com/calculator</loc>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://puffinoffset.com/how-it-works</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://puffinoffset.com/about</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://puffinoffset.com/contact</loc>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>

1
public/yacht-hero.jpg Normal file
View File

@ -0,0 +1 @@
[Binary file content cannot be directly created - please save the provided image as 'yacht-hero.jpg' in the public directory]

25
puffin-calculator.css Normal file
View File

@ -0,0 +1,25 @@
/**
* Puffin Offset Calculator WordPress Plugin Styles
*/
.puffin-calculator-wrapper {
margin: 20px 0;
width: 100%;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.puffin-calculator-iframe {
border: none;
width: 100%;
transition: height 0.3s ease;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.puffin-calculator-wrapper {
margin: 15px 0;
}
}

93
puffin-calculator.js Normal file
View File

@ -0,0 +1,93 @@
/**
* Puffin Offset Calculator WordPress Plugin Script
* This script handles communication between WordPress and the calculator iframe
*/
(function($) {
'use strict';
// Wait for the DOM to be fully loaded
$(document).ready(function() {
// Get all calculator iframes
const calculatorIframes = $('.puffin-calculator-iframe');
if (calculatorIframes.length === 0) {
return;
}
// Process each iframe
calculatorIframes.each(function() {
const iframe = $(this);
// Set up message listener for iframe communication
window.addEventListener('message', function(event) {
// Make sure the message is from our calculator
if (!event.origin.includes('localhost:8080') &&
!event.origin.includes('puffinoffset.com')) {
return;
}
// Handle different message types
if (event.data && typeof event.data === 'object') {
switch (event.data.type) {
case 'resize':
// Resize iframe based on content height
if (event.data.height) {
iframe.height(event.data.height);
}
break;
case 'calculationComplete':
// Handle when a calculation is completed
console.log('Calculation completed:', event.data.result);
// You could trigger WordPress events or update page content here
break;
case 'offsetPurchased':
// Handle when a user purchases an offset
console.log('Offset purchased:', event.data.details);
// You could trigger analytics events or show WordPress notifications
break;
}
}
});
// Send init message to iframe once loaded
iframe.on('load', function() {
const message = {
type: 'init',
source: 'wordpress',
siteInfo: {
name: document.title,
url: window.location.href
}
};
// Post message to iframe
try {
iframe[0].contentWindow.postMessage(message, '*');
} catch (e) {
console.error('Failed to initialize calculator communication:', e);
}
});
});
// Adjust iframe height on window resize
$(window).on('resize', function() {
calculatorIframes.each(function() {
const iframe = $(this);
const message = {
type: 'requestHeight',
source: 'wordpress'
};
try {
iframe[0].contentWindow.postMessage(message, '*');
} catch (e) {
// Iframe might not be loaded yet
}
});
});
});
})(jQuery);

50
setup.sh Normal file
View File

@ -0,0 +1,50 @@
#!/bin/bash
# Puffin Offset Calculator Docker Setup Script
# This script sets up the Docker environment for the Puffin Offset Calculator
echo "========================================"
echo "Puffin Offset Calculator Docker Setup"
echo "========================================"
# Create logs directory if it doesn't exist
if [ ! -d "logs" ]; then
echo "Creating logs directory..."
mkdir -p logs
fi
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo "Error: Docker is not installed. Please install Docker first."
exit 1
fi
# Check if Docker Compose is installed
if ! command -v docker-compose &> /dev/null; then
echo "Error: Docker Compose is not installed. Please install Docker Compose first."
exit 1
fi
echo "Building and starting the Docker container..."
docker-compose up -d --build
if [ $? -eq 0 ]; then
echo "========================================"
echo "Setup completed successfully!"
echo "Your calculator is now available at: http://localhost:8080"
echo ""
echo "Next steps for WordPress integration:"
echo "1. Copy wordpress-integration.php to your WordPress plugins directory"
echo "2. Create the required CSS and JS directories as described in README.docker.md"
echo "3. Activate the plugin in your WordPress admin panel"
echo "4. Use the [puffin_calculator] shortcode on any page or post"
echo ""
echo "For more details, please read the README.docker.md file."
echo "========================================"
else
echo "========================================"
echo "Error: Failed to start the Docker container."
echo "Please check the error messages above and try again."
echo "========================================"
exit 1
fi

229
src/App.tsx Normal file
View File

@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import { Bird, Menu, X } from 'lucide-react';
import { Home } from './components/Home';
import { YachtSearch } from './components/YachtSearch';
import { TripCalculator } from './components/TripCalculator';
import { HowItWorks } from './components/HowItWorks';
import { About } from './components/About';
import { Contact } from './components/Contact';
import { OffsetOrder } from './components/OffsetOrder';
import { getVesselData } from './api/aisClient';
import { calculateCarbon } from './utils/carbonCalculator';
import { analytics } from './utils/analytics';
import type { VesselData, CarbonCalculation, CalculatorType } from './types';
const sampleVessel: VesselData = {
imo: "1234567",
vesselName: "Sample Yacht",
type: "Yacht",
length: 50,
width: 9,
estimatedEnginePower: 2250
};
function App() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [vesselData, setVesselData] = useState<VesselData | null>(null);
const [currentPage, setCurrentPage] = useState<'home' | 'calculator' | 'how-it-works' | 'about' | 'contact'>('home');
const [showOffsetOrder, setShowOffsetOrder] = useState(false);
const [offsetTons, setOffsetTons] = useState(0);
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
useEffect(() => {
analytics.pageView(window.location.pathname);
}, [currentPage]);
const handleSearch = async (imo: string) => {
setLoading(true);
setError(null);
setVesselData(null);
try {
const vessel = await getVesselData(imo);
setVesselData(vessel);
} catch (err) {
setError('Unable to fetch vessel data. Please verify the IMO number and try again.');
} finally {
setLoading(false);
}
};
const handleOffsetClick = (tons: number, monetaryAmount?: number) => {
setOffsetTons(tons);
setMonetaryAmount(monetaryAmount);
setShowOffsetOrder(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleNavigate = (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'contact') => {
setCurrentPage(page);
setMobileMenuOpen(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const renderPage = () => {
if (currentPage === 'calculator' && showOffsetOrder) {
return (
<div className="flex justify-center px-4 sm:px-0">
<OffsetOrder
tons={offsetTons}
monetaryAmount={monetaryAmount}
onBack={() => {
setShowOffsetOrder(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
calculatorType={calculatorType}
/>
</div>
);
}
switch (currentPage) {
case 'calculator':
return (
<div className="flex flex-col items-center px-4 sm:px-6">
<div className="text-center mb-12 max-w-2xl">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4">
Calculate & Offset Your Yacht's Carbon Footprint
</h2>
<p className="text-base sm:text-lg text-gray-600">
Use the calculator below to estimate your carbon footprint and explore offsetting options through our verified projects.
</p>
</div>
<div className="flex flex-col items-center w-full max-w-2xl space-y-8">
<TripCalculator
vesselData={sampleVessel}
onOffsetClick={handleOffsetClick}
/>
</div>
</div>
);
case 'how-it-works':
return <HowItWorks onNavigate={handleNavigate} />;
case 'about':
return <About onNavigate={handleNavigate} />;
case 'contact':
return <Contact />;
default:
return <Home onNavigate={handleNavigate} />;
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-green-50">
<header className="bg-white shadow-sm relative">
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div
className="flex items-center space-x-2 cursor-pointer"
onClick={() => handleNavigate('home')}
>
<Bird className="text-blue-600" size={24} />
<h1 className="text-xl font-bold text-gray-900">Puffin Offset</h1>
</div>
{/* Mobile menu button */}
<button
className="sm:hidden p-2 rounded-md text-gray-600 hover:text-gray-900"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
{/* Desktop navigation */}
<nav className="hidden sm:flex space-x-4">
<button
onClick={() => handleNavigate('calculator')}
className={`text-gray-600 hover:text-gray-900 ${currentPage === 'calculator' ? 'font-semibold' : ''}`}
>
Calculator
</button>
<button
onClick={() => handleNavigate('how-it-works')}
className={`text-gray-600 hover:text-gray-900 ${currentPage === 'how-it-works' ? 'font-semibold' : ''}`}
>
How it Works
</button>
<button
onClick={() => handleNavigate('about')}
className={`text-gray-600 hover:text-gray-900 ${currentPage === 'about' ? 'font-semibold' : ''}`}
>
About
</button>
<button
onClick={() => handleNavigate('contact')}
className={`text-gray-600 hover:text-gray-900 ${currentPage === 'contact' ? 'font-semibold' : ''}`}
>
Contact
</button>
</nav>
</div>
{/* Mobile navigation */}
{mobileMenuOpen && (
<nav className="sm:hidden mt-4 pb-2 space-y-2">
<button
onClick={() => handleNavigate('calculator')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'calculator'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
Calculator
</button>
<button
onClick={() => handleNavigate('how-it-works')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'how-it-works'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
How it Works
</button>
<button
onClick={() => handleNavigate('about')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'about'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
About
</button>
<button
onClick={() => handleNavigate('contact')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'contact'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
Contact
</button>
</nav>
)}
</div>
</header>
<main className="max-w-7xl mx-auto py-8 sm:py-12">
{renderPage()}
</main>
<footer className="bg-white mt-16">
<div className="max-w-7xl mx-auto px-4 py-8 sm:px-6 lg:px-8">
<p className="text-center text-gray-500">
Powered by Wren Carbon Offset Projects
</p>
</div>
</footer>
</div>
);
}
export default App;

72
src/api/aisClient.ts Normal file
View File

@ -0,0 +1,72 @@
import axios from 'axios';
import type { VesselData } from '../types';
// Using MarineTraffic API as an example - you'll need to add your API key
const API_KEY = import.meta.env.VITE_MARINE_TRAFFIC_API_KEY;
const API_BASE_URL = 'https://services.marinetraffic.com/api/vesselmasterdata/v3';
export async function getVesselData(imo: string): Promise<VesselData> {
// For development, return mock data if no API key is present
if (!API_KEY) {
console.warn('No API key found - using mock data');
return getMockVesselData(imo);
}
try {
const response = await axios.get(API_BASE_URL, {
params: {
imo,
apikey: API_KEY,
}
});
if (!response.data || response.data.errors) {
throw new Error('Vessel not found');
}
const data = response.data[0]; // API returns an array
return {
imo: imo,
vesselName: data.VESSEL_NAME || 'Unknown',
type: data.SHIP_TYPE || 'Unknown',
length: Number(data.LENGTH) || 0,
width: Number(data.BREADTH) || 0,
estimatedEnginePower: calculateEstimatedEnginePower(
Number(data.LENGTH),
Number(data.BREADTH),
data.SHIP_TYPE
)
};
} catch (error) {
console.error('AIS API Error:', error);
throw new Error('Failed to fetch vessel data. Please check your IMO number and try again.');
}
}
function calculateEstimatedEnginePower(length: number, width: number, type: string): number {
// Simplified power estimation based on vessel dimensions
const baselinePower = length * width * 5; // kW
// Apply vessel type multiplier
const typeMultiplier = {
'Yacht': 1.2,
'Passenger': 1.5,
'Cargo': 1.0,
'default': 1.0
}[type] || 1.0;
return Math.round(baselinePower * typeMultiplier);
}
// Mock data for development
function getMockVesselData(imo: string): VesselData {
return {
imo: imo,
vesselName: "Sample Yacht",
type: "Yacht",
length: 50,
width: 9,
estimatedEnginePower: 2250 // 50 * 9 * 5
};
}

219
src/api/wrenClient.ts Normal file
View File

@ -0,0 +1,219 @@
import axios from 'axios';
import type { OffsetOrder, Portfolio } from '../types';
import { config } from '../utils/config';
// Default portfolio for fallback
const DEFAULT_PORTFOLIO: 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'
};
// Create API client with error handling, timeout, and retry logic
const createApiClient = () => {
if (!config.wrenApiKey) {
throw new Error('Wren API token is not configured');
}
const client = axios.create({
baseURL: 'https://api.wren.co/v1',
headers: {
'Authorization': `Bearer ${config.wrenApiKey}`,
'Content-Type': 'application/json'
},
timeout: 10000, // 10 second timeout
validateStatus: (status) => status >= 200 && status < 500, // Handle 4xx errors gracefully
});
// Add request interceptor for logging
client.interceptors.request.use(
(config) => {
if (!config.headers.Authorization) {
throw new Error('API token is required');
}
return config;
},
(error) => {
console.error('Request configuration error:', error.message);
return Promise.reject(error);
}
);
// Add response interceptor for error handling
client.interceptors.response.use(
(response) => response,
(error) => {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
console.warn('Request timeout, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
if (!error.response) {
console.warn('Network error, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
if (error.response.status === 401) {
console.warn('Authentication failed, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
}
return Promise.reject(error);
}
);
return client;
};
// Safe error logging function that handles non-serializable objects
const logError = (error: unknown) => {
if (error instanceof Error) {
const errorInfo = {
name: error.name,
message: error.message,
stack: error.stack
};
console.error('API Error:', JSON.stringify(errorInfo, null, 2));
} else {
console.error('Unknown error:', String(error));
}
};
export async function getPortfolios(): Promise<Portfolio[]> {
try {
if (!config.wrenApiKey) {
console.warn('No Wren API token configured, using fallback portfolio');
return [DEFAULT_PORTFOLIO];
}
const api = createApiClient();
const response = await api.get('/portfolios');
if (!response.data?.portfolios?.length) {
console.warn('No portfolios returned from API, using fallback');
return [DEFAULT_PORTFOLIO];
}
return response.data.portfolios.map((portfolio: any) => {
let pricePerTon = 200; // Default price
if (portfolio.price_per_ton !== undefined && portfolio.price_per_ton !== null) {
const rawPrice = portfolio.price_per_ton;
if (typeof rawPrice === 'number') {
pricePerTon = rawPrice;
} else if (typeof rawPrice === 'string') {
const parsed = parseFloat(rawPrice);
if (!isNaN(parsed)) {
pricePerTon = parsed;
}
}
}
return {
id: portfolio.id,
name: portfolio.name,
description: portfolio.description || '',
projects: portfolio.projects || [],
pricePerTon,
currency: 'USD'
};
});
} catch (error) {
logError(error);
console.warn('Failed to fetch portfolios from API, using fallback');
return [DEFAULT_PORTFOLIO];
}
}
export async function createOffsetOrder(
portfolioId: number,
tons: number,
dryRun: boolean = false
): Promise<OffsetOrder> {
try {
if (!config.wrenApiKey) {
throw new Error('Carbon offset service is currently unavailable. Please contact support.');
}
const api = createApiClient();
const response = await api.post('/orders', {
portfolio_id: portfolioId,
tons,
dry_run: dryRun,
currency: 'USD'
});
const order = response.data;
let pricePerTon = 200;
if (order.portfolio?.price_per_ton !== undefined && order.portfolio.price_per_ton !== null) {
const rawPrice = order.portfolio.price_per_ton;
pricePerTon = typeof rawPrice === 'number' ? rawPrice : parseFloat(rawPrice) || 200;
}
return {
id: order.id,
amountCharged: order.amount_charged,
currency: 'USD',
tons: order.tons,
portfolio: {
id: order.portfolio.id,
name: order.portfolio.name,
description: order.portfolio.description || '',
projects: order.portfolio.projects || [],
pricePerTon,
currency: 'USD'
},
status: order.status,
createdAt: order.created_at,
dryRun: order.dry_run
};
} catch (error) {
logError(error);
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
throw new Error('Request timed out. Please try again.');
}
if (!error.response) {
throw new Error('Network error. Please check your connection and try again.');
}
if (error.response.status === 401) {
throw new Error('Carbon offset service is currently unavailable. Please try again later or contact support.');
}
}
throw new Error('Failed to create offset order. Please try again.');
}
}

98
src/components/About.tsx Normal file
View File

@ -0,0 +1,98 @@
import React from 'react';
import { Anchor, Heart, Leaf, Scale, CreditCard, FileCheck, Handshake, Rocket } from 'lucide-react';
interface Props {
onNavigate: (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'contact') => void;
}
export function About({ onNavigate }: Props) {
const handleStartOffsetting = () => {
onNavigate('calculator');
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 0);
};
return (
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">About Puffin Offset</h1>
<p className="text-xl text-gray-600">
Leading the way in maritime carbon offsetting solutions
</p>
</div>
<div className="prose prose-lg text-gray-600 mb-12">
<p className="text-justify">
Puffin Offset was founded with a clear mission: to make carbon offsetting accessible and effective for the maritime industry. We understand the unique challenges faced by yacht owners and operators in reducing their environmental impact while maintaining the highest standards of luxury and service.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-12">
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center space-x-3 mb-4">
<Heart className="text-red-500" size={24} />
<h2 className="text-xl font-bold text-gray-900">Our Mission</h2>
</div>
<p className="text-gray-600 text-justify">
To empower the maritime industry with effective, transparent, and accessible carbon offsetting solutions that make a real difference in the fight against climate change.
</p>
</div>
<div className="bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center space-x-3 mb-4">
<Leaf className="text-green-500" size={24} />
<h2 className="text-xl font-bold text-gray-900">Our Impact</h2>
</div>
<p className="text-gray-600 text-justify">
Through our partnerships with verified carbon offset projects, we are able to help maritime businesses offset thousands of tons of CO emissions and support sustainable development worldwide.
</p>
</div>
</div>
<div className="bg-white rounded-lg shadow-lg p-8 mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Our Values</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="flex items-start space-x-3">
<Scale className="text-blue-500 flex-shrink-0" size={24} />
<div>
<h3 className="font-semibold text-gray-900 mb-2">Transparency</h3>
<p className="text-gray-600 text-justify">Clear, honest reporting on the impact of every offset.</p>
</div>
</div>
<div className="flex items-start space-x-3">
<FileCheck className="text-green-500 flex-shrink-0" size={24} />
<div>
<h3 className="font-semibold text-gray-900 mb-2">Quality</h3>
<p className="text-gray-600 text-justify">Only the highest standard of verified offset projects.</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Handshake className="text-purple-500 flex-shrink-0" size={24} />
<div>
<h3 className="font-semibold text-gray-900 mb-2">Partnership</h3>
<p className="text-gray-600 text-justify">Working together for a sustainable future.</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Rocket className="text-orange-500 flex-shrink-0" size={24} />
<div>
<h3 className="font-semibold text-gray-900 mb-2">Future Proof</h3>
<p className="text-gray-600 text-justify">Constantly improving our service and offsetting products.</p>
</div>
</div>
</div>
</div>
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Ready to Make a Difference?</h2>
<button
onClick={handleStartOffsetting}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Start Offsetting Today
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { Leaf } from 'lucide-react';
import type { CarbonCalculation, CurrencyCode } from '../types';
import { currencies, formatCurrency } from '../utils/currencies';
import { CurrencySelect } from './CurrencySelect';
import { calculateCarbonFromDistance } from '../utils/carbonCalculator';
interface Props {
calculation: CarbonCalculation;
onOffsetClick?: (tons: number) => void;
}
export function CarbonOffset({ calculation, onOffsetClick }: Props) {
const [calculationType, setCalculationType] = useState<'distance' | 'fuel'>('distance');
const [annualDistance, setAnnualDistance] = useState<string>('');
const [fuelAmount, setFuelAmount] = useState<string>('');
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
const [currency, setCurrency] = useState<CurrencyCode>('USD');
const [offsetPercentage, setOffsetPercentage] = useState<number>(100);
const [customPercentage, setCustomPercentage] = useState<string>('');
const selectedCurrency = currencies[currency];
const handleCustomPercentageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || (Number(value) >= 0 && Number(value) <= 100)) {
setCustomPercentage(value);
if (value !== '') {
setOffsetPercentage(Number(value));
}
}
};
const handlePresetPercentage = (percentage: number) => {
setOffsetPercentage(percentage);
setCustomPercentage('');
};
const calculateOffsetAmount = (emissions: number, percentage: number) => {
return (emissions * percentage) / 100;
};
const getEmissions = () => {
if (calculationType === 'distance' && annualDistance) {
return calculateCarbonFromDistance(Number(annualDistance));
}
return calculation.yearlyEmissions;
};
const emissions = getEmissions();
const offsetAmount = calculateOffsetAmount(emissions, offsetPercentage);
const offsetCost = (offsetAmount * 20); // $20 per ton
return (
<div className="bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-800">Annual Carbon Offset Summary</h2>
<Leaf className="text-green-500" size={24} />
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Calculation Method
</label>
<div className="flex space-x-4">
<button
type="button"
onClick={() => setCalculationType('distance')}
className={`px-4 py-2 rounded-lg transition-colors ${
calculationType === 'distance'
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Estimated Distance
</button>
<button
type="button"
onClick={() => setCalculationType('fuel')}
className={`px-4 py-2 rounded-lg transition-colors ${
calculationType === 'fuel'
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Fuel Based
</button>
</div>
</div>
{calculationType === 'distance' && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700">
Annual Distance (nautical miles)
</label>
<input
type="number"
min="1"
value={annualDistance}
onChange={(e) => setAnnualDistance(e.target.value)}
placeholder="Enter annual distance"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-200"
/>
</div>
)}
{calculationType === 'fuel' && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700">
Annual Fuel Consumption
</label>
<div className="flex space-x-4">
<div className="flex-1">
<input
type="number"
min="1"
value={fuelAmount}
onChange={(e) => setFuelAmount(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-200"
required
/>
</div>
<div>
<select
value={fuelUnit}
onChange={(e) => setFuelUnit(e.target.value as 'liters' | 'gallons')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-200"
>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</div>
</div>
</div>
)}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Offset Percentage
</label>
<div className="flex flex-wrap gap-3 mb-3">
{[100, 50, 25].map((percent) => (
<button
key={percent}
onClick={() => handlePresetPercentage(percent)}
className={`px-4 py-2 rounded-lg transition-colors ${
offsetPercentage === percent && customPercentage === ''
? 'bg-green-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{percent}%
</button>
))}
<div className="flex items-center space-x-2">
<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-green-500 focus:border-green-500"
/>
<span className="text-gray-600">%</span>
</div>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Selected Offset Amount</p>
<p className="text-2xl font-bold text-gray-900">
{offsetAmount.toFixed(2)} tons CO
</p>
<p className="text-sm text-gray-500">
{offsetPercentage}% of {emissions.toFixed(2)} tons
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Estimated Offset Cost</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(offsetCost, selectedCurrency)}
</p>
</div>
</div>
<button
onClick={() => {
if (onOffsetClick) {
onOffsetClick(offsetAmount);
}
}}
className="w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors"
>
Offset Your Impact
</button>
</div>
);
}

217
src/components/Contact.tsx Normal file
View File

@ -0,0 +1,217 @@
import React, { useState } from 'react';
import { Mail, Phone, Loader2, AlertCircle } from 'lucide-react';
import { validateEmail, sendFormspreeEmail } from '../utils/email';
import { analytics } from '../utils/analytics';
export function Contact() {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: ''
});
const [submitted, setSubmitted] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSending(true);
setError(null);
try {
// Validate email
if (!validateEmail(formData.email)) {
throw new Error('Please enter a valid email address');
}
// Send via Formspree
await sendFormspreeEmail(formData, 'contact');
setSubmitted(true);
analytics.event('contact', 'form_submitted');
// Reset form after delay
setTimeout(() => {
setFormData({
name: '',
email: '',
phone: '',
company: '',
message: ''
});
setSubmitted(false);
}, 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send message. Please try again.');
analytics.error(err as Error, 'Contact form submission failed');
} finally {
setSending(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Contact Us</h1>
<p className="text-xl text-gray-600">
Ready to start your sustainability journey? Get in touch with our team today.
</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-8 sm:p-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 sm:gap-12">
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Get in Touch</h2>
<p className="text-gray-600 mb-8 text-justify">
Have questions about our carbon offsetting solutions? Our team is here to help you make a difference in maritime sustainability.
</p>
</div>
<div className="space-y-6">
<div className="flex items-center space-x-4">
<Mail className="text-blue-600" size={24} />
<div>
<h3 className="font-semibold text-gray-900">Email Us</h3>
<a
href="mailto:info@puffinoffset.com"
className="text-blue-600 hover:text-blue-700"
>
info@puffinoffset.com
</a>
</div>
</div>
<div className="flex items-center space-x-4">
<Phone className="text-blue-600" size={24} />
<div>
<h3 className="font-semibold text-gray-900">Call Us</h3>
<a
href="tel:+33671187253"
className="text-blue-600 hover:text-blue-700"
>
+33 6 71 18 72 53
</a>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{submitted && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-700">
Thank you for your message. Your email client will open shortly.
</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-700">{error}</p>
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="+1 234 567 8900"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
Company
</label>
<input
type="text"
id="company"
name="company"
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
Message *
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={4}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
></textarea>
</div>
<button
type="submit"
disabled={sending}
className={`w-full flex items-center justify-center bg-blue-600 text-white py-3 rounded-lg transition-colors ${
sending ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-700'
}`}
>
{sending ? (
<>
<Loader2 className="animate-spin mr-2" size={20} />
Preparing Email...
</>
) : (
'Send Message'
)}
</button>
<p className="text-sm text-gray-500 text-center">
* Required fields
</p>
</form>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import React from 'react';
import type { CurrencyCode } from '../types';
import { currencies } from '../utils/currencies';
interface Props {
value: CurrencyCode;
onChange: (currency: CurrencyCode) => void;
}
export function CurrencySelect({ value, onChange }: Props) {
return (
<select
value={value}
onChange={(e) => onChange(e.target.value as CurrencyCode)}
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"
>
{Object.entries(currencies).map(([code, currency]) => (
<option key={code} value={code}>
{currency.symbol} {code}
</option>
))}
</select>
);
}

View File

@ -0,0 +1,54 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle } from 'lucide-react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
<div className="flex items-center justify-center mb-6">
<AlertCircle className="text-red-500" size={48} />
</div>
<h1 className="text-2xl font-bold text-gray-900 text-center mb-4">
Something went wrong
</h1>
<p className="text-gray-600 text-center mb-6">
We apologize for the inconvenience. Please try refreshing the page or contact support if the problem persists.
</p>
<button
onClick={() => window.location.reload()}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors"
>
Refresh Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}

106
src/components/Home.tsx Normal file
View File

@ -0,0 +1,106 @@
import React from 'react';
import { Anchor, Globe, BarChart } from 'lucide-react';
interface Props {
onNavigate: (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'contact') => void;
}
export function Home({ onNavigate }: Props) {
const handleCalculateClick = () => {
onNavigate('calculator');
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 0);
};
const handleLearnMoreClick = () => {
onNavigate('about');
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 0);
};
return (
<div className="max-w-7xl mx-auto">
<div className="relative mb-16">
<div className="relative h-[500px]">
<img
src="https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?auto=format&fit=crop&q=80"
alt="Luxury yacht on calm waters"
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 via-transparent to-green-500/20" />
</div>
<div className="absolute inset-0 flex items-end pb-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-5xl font-bold text-white mb-8 drop-shadow-lg">
Set Sail Sustainably with Carbon Offsetting for Superyachts
</h1>
<p className="text-xl text-white max-w-3xl mx-auto leading-relaxed drop-shadow-lg">
Luxury and environmental responsibility can go hand in hand when you choose to offset the carbon footprint of your superyacht adventures.
</p>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
<div className="bg-white rounded-xl shadow-lg p-8 transform hover:scale-105 transition-transform duration-300">
<div className="flex items-center space-x-4 mb-6">
<Globe className="text-blue-600" size={32} />
<h2 className="text-2xl font-bold text-gray-900">Flexible Offsetting Solutions</h2>
</div>
<p className="text-gray-600 leading-relaxed text-justify">
With Puffin's carbon offsetting program, it's simple to mitigate the environmental impact of a yacht's use by supporting impactful international projects. Whether you want to offset a portion of a single trip, a season, or a yacht's full annual emissions, Puffin gives you the flexibility to offset as much or as little as you like.
</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-8 transform hover:scale-105 transition-transform duration-300">
<div className="flex items-center space-x-4 mb-6">
<BarChart className="text-green-600" size={32} />
<h2 className="text-2xl font-bold text-gray-900">Your Values, Your Choice</h2>
</div>
<p className="text-gray-600 leading-relaxed text-justify">
Our portfolios are designed to resonate with the values of our most environmentally-conscious clients, ensuring contributions align with their passion for a better planet. Our science-based, verified carbon offsetting projects have a real and ongoing impact in the fight against climate change.
</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg p-12 mb-16 text-center">
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-center space-x-4 mb-6">
<Anchor className="text-blue-600" size={32} />
<h2 className="text-3xl font-bold text-gray-900">
Empower Your Yacht Business with In-House Offsetting
</h2>
</div>
<p className="text-lg text-gray-600 leading-relaxed text-justify">
Our offsetting tool is not only perfect for charter guests and yacht owners, it can also be used by yacht management companies and brokerage firms seeking to integrate sustainability into the entirety of their operations. Use Puffin to offer clients carbon-neutral charter options or manage the environmental footprint of your fleet. Showcase your commitment to eco-conscious luxury while adding value to your services and elevating your brand.
</p>
</div>
</div>
<div className="text-center bg-white rounded-xl shadow-lg p-12 mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-6">
Ready to Make a Difference?
</h2>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
Join the growing community of environmentally conscious yacht owners and operators who are leading the way in maritime sustainability.
</p>
<div className="flex justify-center space-x-4">
<button
onClick={handleCalculateClick}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Calculate Your Impact
</button>
<button
onClick={handleLearnMoreClick}
className="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-lg hover:bg-blue-50 transition-colors"
>
Learn More
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import { Leaf, Anchor, Calculator, Globe, BarChart } from 'lucide-react';
interface Props {
onNavigate?: (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'contact') => void;
}
export function HowItWorks({ onNavigate }: Props) {
const handleOffsetClick = () => {
onNavigate?.('calculator');
setTimeout(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, 0);
};
return (
<div className="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<div className="flex justify-center items-center space-x-3 mb-6">
<Leaf className="text-green-500" size={32} />
<Anchor className="text-blue-500" size={32} />
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">How It Works</h1>
</div>
<div className="space-y-12">
<section className="bg-white rounded-lg shadow-lg p-8">
<div className="flex items-center space-x-4 mb-6">
<Calculator className="text-blue-500" size={28} />
<h2 className="text-2xl font-bold text-gray-900">1. Calculate Your Impact</h2>
</div>
<div className="prose prose-lg text-gray-600">
<p className="text-justify mb-4">
Enter your vessel's fuel usage or nautical miles travelled to calculate how many tons of CO2 have been produced.
Choose between calculating emissions for specific trips or annual operations to get a precise understanding of your environmental impact.
</p>
</div>
</section>
<section className="bg-white rounded-lg shadow-lg p-8">
<div className="flex items-center space-x-4 mb-6">
<Globe className="text-green-500" size={28} />
<h2 className="text-2xl font-bold text-gray-900">2. Select Your Offset Project</h2>
</div>
<div className="prose prose-lg text-gray-600">
<p className="text-justify mb-4">
Choose the percentage of CO2 production you would like to offset via our curated carbon offset portfolio. Each project is thoroughly vetted and monitored to ensure your contribution creates real, measurable impact in reducing global carbon emissions. Alternatively, contact us direct to design a bespoke offsetting product specifically tailored to your needs, including tax-deductible offsets for US customers.
</p>
</div>
</section>
<section className="bg-white rounded-lg shadow-lg p-8">
<div className="flex items-center space-x-4 mb-6">
<BarChart className="text-blue-500" size={28} />
<h2 className="text-2xl font-bold text-gray-900">3. Track Your Impact</h2>
</div>
<div className="prose prose-lg text-gray-600">
<p className="text-justify mb-4">
Sign up to stay connected to your environmental impact through:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>Regular project updates and progress reports</li>
<li>Detailed emissions reduction tracking</li>
<li>Impact certificates for your offset contributions</li>
<li>Transparent project performance metrics</li>
</ul>
<p className="text-justify mt-4">
Monitor your contribution to global sustainability efforts and share your commitment to environmental stewardship with others in the yachting community.
</p>
</div>
</section>
<section className="bg-gradient-to-r from-blue-50 to-green-50 rounded-lg shadow-lg p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Ready to Make a Difference?</h2>
<div className="prose prose-lg text-gray-600 mb-8">
<p>
Start your carbon offsetting journey today and join the growing community of environmentally conscious yacht owners who are leading the way in maritime sustainability.
</p>
</div>
<button
onClick={handleOffsetClick}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Offset Your Impact
</button>
</section>
</div>
</div>
);
}

View File

@ -0,0 +1,399 @@
import React, { useState, useEffect } from 'react';
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind } from 'lucide-react';
import { createOffsetOrder, getPortfolios } from '../api/wrenClient';
import type { CurrencyCode, OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currencies';
import { config } from '../utils/config';
import { sendFormspreeEmail } from '../utils/email';
interface Props {
tons: number;
monetaryAmount?: number;
onBack: () => void;
calculatorType: 'trip' | 'annual';
}
interface ProjectTypeIconProps {
project: OffsetProject;
}
const ProjectTypeIcon = ({ project }: ProjectTypeIconProps) => {
// Safely check if project and type exist
if (!project || !project.type) {
return <Globe2 className="text-blue-500" />;
}
const type = project.type.toLowerCase();
switch (type) {
case 'direct air capture':
return <Factory className="text-purple-500" />;
case 'blue carbon':
return <Waves className="text-blue-500" />;
case 'renewable energy':
return <Wind className="text-green-500" />;
case 'forestry':
return <TreePine className="text-green-500" />;
default:
return <Globe2 className="text-blue-500" />;
}
};
export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [order, setOrder] = useState<OffsetOrderType | null>(null);
const [currency, setCurrency] = useState<CurrencyCode>('USD');
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: `I would like to offset ${tons.toFixed(2)} tons of CO2 from my yacht's ${calculatorType} emissions.`
});
useEffect(() => {
if (!config.wrenApiKey) {
setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.');
setLoadingPortfolio(false);
return;
}
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 renderPortfolioPrice = (portfolio: 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);
return (
<div className="bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full">
<button
onClick={onBack}
className="flex items-center text-gray-600 hover:text-gray-900 mb-6"
>
<ArrowLeft className="mr-2" size={20} />
Back to Calculator
</button>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Offset Your Impact
</h2>
<p className="text-lg text-gray-600">
You're about to offset {tons.toFixed(2)} tons of CO
</p>
</div>
{error && !config.wrenApiKey ? (
<div className="max-w-2xl mx-auto">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8">
<h3 className="text-xl font-semibold text-blue-900 mb-4">
Contact Us for Offsetting
</h3>
<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.
</p>
<form onSubmit={async (e) => {
e.preventDefault();
setLoading(true);
try {
await sendFormspreeEmail(formData, 'offset');
setSuccess(true);
} catch (err) {
setError('Failed to send request. Please try again.');
} finally {
setLoading(false);
}
}} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
required
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
required
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company
</label>
<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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Message
</label>
<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"
/>
</div>
<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 ? (
<>
<Loader2 className="animate-spin mr-2" size={20} />
Sending Request...
</>
) : (
'Send Offset Request'
)}
</button>
</form>
</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<div className="flex items-center space-x-2">
<AlertCircle className="text-red-500" size={20} />
<p className="text-red-700">{error}</p>
</div>
</div>
) : success && order ? (
<div className="text-center py-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-6">
<Check className="text-green-500" size={32} />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">
Offset Order Successful!
</h3>
<p className="text-gray-600 mb-6">
Your order has been processed successfully. You'll receive a confirmation email shortly.
</p>
<div className="bg-gray-50 rounded-lg p-6 mb-6">
<h4 className="text-lg font-semibold text-gray-900 mb-4">Order Summary</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">Order ID:</span>
<span className="font-medium">{order.id}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Amount:</span>
<span className="font-medium">
{formatCurrency(order.amountCharged / 100, currencies[order.currency])}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">CO Offset:</span>
<span className="font-medium">{order.tons} tons</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Portfolio:</span>
<span className="font-medium">{order.portfolio.name}</span>
</div>
</div>
</div>
<button
onClick={onBack}
className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition-colors"
>
Back to Calculator
</button>
</div>
) : loadingPortfolio ? (
<div className="flex justify-center items-center py-12">
<Loader2 className="animate-spin text-blue-500" size={32} />
<span className="ml-2 text-gray-600">Loading portfolio information...</span>
</div>
) : portfolio ? (
<>
<div className="bg-white border rounded-lg p-6 mb-8">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
{portfolio.name}
</h3>
<p className="text-gray-600 mb-6">
{portfolio.description}
</p>
{portfolio.projects && portfolio.projects.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{portfolio.projects.map((project) => (
<div key={project.id} className="bg-gray-50 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-center space-x-2 mb-3">
<ProjectTypeIcon project={project} />
<h4 className="font-semibold text-gray-900">{project.name}</h4>
</div>
{project.imageUrl && (
<div className="relative h-32 mb-3 rounded-lg overflow-hidden">
<img
src={project.imageUrl}
alt={project.name}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
)}
<p className="text-sm text-gray-600 mb-3">
{project.shortDescription || project.description}
</p>
<div className="space-y-1 text-sm">
{project.location && (
<div className="flex justify-between">
<span className="text-gray-500">Location:</span>
<span className="text-gray-900">{project.location}</span>
</div>
)}
{project.type && (
<div className="flex justify-between">
<span className="text-gray-500">Type:</span>
<span className="text-gray-900">{project.type}</span>
</div>
)}
{project.verificationStandard && (
<div className="flex justify-between">
<span className="text-gray-500">Standard:</span>
<span className="text-gray-900">{project.verificationStandard}</span>
</div>
)}
{project.impactMetrics?.co2Reduced && (
<div className="flex justify-between">
<span className="text-gray-500">Impact:</span>
<span className="text-gray-900">{project.impactMetrics.co2Reduced.toLocaleString()} tons CO</span>
</div>
)}
</div>
</div>
))}
</div>
)}
<div className="flex items-center justify-between bg-blue-50 p-4 rounded-lg">
<span className="text-blue-900 font-medium">Portfolio Price per Ton:</span>
<span className="text-blue-900 font-bold text-lg">
{renderPortfolioPrice(portfolio)}
</span>
</div>
</div>
<div className="bg-gray-50 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Order Summary</h3>
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-gray-600">Amount to Offset:</span>
<span className="font-medium">{tons.toFixed(2)} tons CO</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Portfolio Distribution:</span>
<span className="font-medium">Automatically optimized</span>
</div>
<div className="border-t pt-4">
<div className="flex justify-between">
<span className="text-gray-900 font-semibold">Total Cost:</span>
<span className="text-gray-900 font-semibold">
{formatCurrency(offsetCost, getCurrencyByCode(portfolio.currency))}
</span>
</div>
</div>
</div>
</div>
<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 ? (
<div className="flex items-center justify-center">
<Loader2 className="animate-spin mr-2" size={20} />
Processing...
</div>
) : (
'Confirm Offset Order'
)}
</button>
</>
) : null}
</div>
);
}

View File

@ -0,0 +1,88 @@
import React from 'react';
import { Laptop, Leaf, Scale, CreditCard, FileCheck, Handshake } from 'lucide-react';
interface Props {
onNavigate: (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'advantage') => void;
}
export function PuffinAdvantage({ onNavigate }: Props) {
const advantages = [
{
icon: <Laptop className="text-blue-500" size={24} />,
title: "Technology-Driven Convenience",
description: "The Puffin platform provides instant offsetting calculations and solutions, making carbon management effortless for yacht owners, charterers and operators anywhere in the world."
},
{
icon: <Leaf className="text-green-500" size={24} />,
title: "Diverse Offset Portfolio",
description: "Puffin offers a carefully curated selection of high-impact projects - from enhanced weathering and refrigerant destruction to marine conservation initiatives, some carrying significant tax advantages."
},
{
icon: <FileCheck className="text-teal-500" size={24} />,
title: "Verified & Certified Offsetting",
description: "All of our offsetting projects are certified by qualified third parties to ensure their quality. Additionally, projects are insured, guaranteeing your offsetting truly makes an impact."
},
{
icon: <CreditCard className="text-purple-500" size={24} />,
title: "Subscriptions or Pay-As-You-Sail",
description: "Puffin's flexible pricing options can be tailored for everyone, from individual yacht owners to large fleet operators. Users can also choose to customise their level of offsetting, starting from a percentage of a single trip, up to a vessel's annual emissions."
},
{
icon: <Scale className="text-indigo-500" size={24} />,
title: "Scalability for Fleet Management",
description: "Puffin also offers purpose-built solutions designed for managing emissions across multiple vessels, perfect for fleet operators and management companies. Contact us for further information and bespoke project offerings."
},
{
icon: <Handshake className="text-orange-500" size={24} />,
title: "Partnerships and Integration",
description: "As well as the basic offsetting services offered through the Puffin website, we also offer integration and strategic partnerships across the maritime industry, tailored to your needs."
}
];
return (
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
The Puffin Advantage
</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Experience the future of yacht carbon offsetting with our technologically advanced platform, designed specifically for the unique needs of the maritime industry.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{advantages.map((advantage, index) => (
<div
key={index}
className="bg-white rounded-lg shadow-lg p-6 hover:shadow-xl transition-shadow duration-300"
>
<div className="flex items-center justify-center w-12 h-12 bg-gray-50 rounded-lg mb-4">
{advantage.icon}
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">
{advantage.title}
</h3>
<p className="text-gray-600">
{advantage.description}
</p>
</div>
))}
</div>
<div className="mt-16 bg-gradient-to-r from-blue-50 to-green-50 rounded-xl p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Ready to Transform Your Environmental Impact?
</h2>
<p className="text-lg text-gray-600 mb-6">
Join the growing community of environmentally conscious yacht operators making a difference with Puffin Offset.
</p>
<button
onClick={() => onNavigate('calculator')}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Get Started Today
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,339 @@
import React, { useState, useCallback } from 'react';
import { Route } from 'lucide-react';
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
import { currencies, formatCurrency } from '../utils/currencies';
import { CurrencySelect } from './CurrencySelect';
interface Props {
vesselData: VesselData;
onOffsetClick?: (tons: number, monetaryAmount?: number) => void;
}
export function TripCalculator({ vesselData, onOffsetClick }: Props) {
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>('fuel');
const [distance, setDistance] = useState<string>('');
const [speed, setSpeed] = useState<string>('12');
const [fuelRate, setFuelRate] = useState<string>('100');
const [fuelAmount, setFuelAmount] = useState<string>('');
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
const [currency, setCurrency] = useState<CurrencyCode>('USD');
const [offsetPercentage, setOffsetPercentage] = useState<number>(100);
const [customPercentage, setCustomPercentage] = useState<string>('');
const [customAmount, setCustomAmount] = useState<string>('');
const handleCalculate = useCallback((e: React.FormEvent) => {
e.preventDefault();
if (calculationType === 'distance') {
const estimate = calculateTripCarbon(
vesselData,
Number(distance),
Number(speed),
Number(fuelRate)
);
setTripEstimate(estimate);
} else if (calculationType === 'fuel') {
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
setTripEstimate({
distance: 0,
duration: 0,
fuelConsumption: Number(fuelAmount),
co2Emissions
});
}
}, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]);
const handleCustomPercentageChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || (Number(value) >= 0 && Number(value) <= 100)) {
setCustomPercentage(value);
if (value !== '') {
setOffsetPercentage(Number(value));
}
}
}, []);
const handlePresetPercentage = useCallback((percentage: number) => {
setOffsetPercentage(percentage);
setCustomPercentage('');
}, []);
const calculateOffsetAmount = useCallback((emissions: number, percentage: number) => {
return (emissions * percentage) / 100;
}, []);
const handleCustomAmountChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value === '' || Number(value) >= 0) {
setCustomAmount(value);
}
}, []);
return (
<div className="bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full mt-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-800">Carbon Offset Calculator</h2>
<Route className="text-blue-500" size={24} />
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Calculation Method
</label>
<div className="flex flex-wrap gap-3">
<button
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
</button>
<button
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
</button>
<button
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
</button>
</div>
</div>
{calculationType === 'custom' ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter Amount to Offset
</label>
<div className="relative rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-500 sm:text-sm">{currencies[currency].symbol}</span>
</div>
<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"
required
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-500 sm:text-sm">{currency}</span>
</div>
</div>
</div>
{customAmount && Number(customAmount) > 0 && (
<button
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
</button>
)}
</div>
) : (
<form onSubmit={handleCalculate} className="space-y-4">
{calculationType === 'fuel' && (
<div>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption
</label>
<div className="flex space-x-4">
<div className="flex-1">
<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"
required
/>
</div>
<div>
<select
value={fuelUnit}
onChange={(e) => setFuelUnit(e.target.value as 'liters' | 'gallons')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</div>
</div>
</div>
)}
{calculationType === 'distance' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">
Distance (nautical miles)
</label>
<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"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Average Speed (knots)
</label>
<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"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption Rate (liters per hour)
</label>
<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"
required
/>
<p className="mt-1 text-sm text-gray-500">
Typical range: 50 - 500 liters per hour for most yachts
</p>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors"
>
Calculate Impact
</button>
</form>
)}
{tripEstimate && calculationType !== 'custom' && (
<div className="mt-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
{calculationType === 'distance' && (
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Trip Duration</p>
<p className="text-xl font-bold text-gray-900">
{tripEstimate.duration.toFixed(1)} hours
</p>
</div>
)}
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Fuel Consumption</p>
<p className="text-xl font-bold text-gray-900">
{tripEstimate.fuelConsumption.toLocaleString()} {fuelUnit}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Offset Percentage
</label>
<div className="flex flex-wrap gap-3 mb-3">
{[100, 75, 50, 25].map((percent) => (
<button
key={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}%
</button>
))}
<div className="flex items-center space-x-2">
<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"
/>
<span className="text-gray-600">%</span>
</div>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Selected CO Offset</p>
<p className="text-2xl font-bold text-blue-900">
{calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons
</p>
<p className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {tripEstimate.co2Emissions.toFixed(2)} tons
</p>
</div>
<button
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
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import { Search } from 'lucide-react';
interface Props {
onSearch: (imo: string) => void;
}
export function YachtSearch({ onSearch }: Props) {
const [imo, setImo] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const cleanedImo = imo.trim().replace(/[^0-9]/g, '');
if (cleanedImo.length === 7) {
onSearch(cleanedImo);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/[^0-9]/g, '').slice(0, 7);
setImo(value);
};
return (
<form onSubmit={handleSubmit} className="w-full max-w-md">
<div className="relative">
<input
type="text"
value={imo}
onChange={handleChange}
placeholder="Enter Vessel IMO Number (7 digits)"
pattern="[0-9]{7}"
maxLength={7}
className="w-full px-4 py-2 text-gray-700 bg-white border rounded-lg focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={imo.length !== 7}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-gray-600 hover:text-blue-500 disabled:opacity-50 disabled:hover:text-gray-600"
>
<Search size={20} />
</button>
</div>
{imo.length > 0 && imo.length < 7 && (
<p className="mt-2 text-sm text-gray-600">IMO number must be 7 digits</p>
)}
</form>
);
}

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import { ErrorBoundary } from './components/ErrorBoundary';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>
);

18
src/test/setup.ts Normal file
View File

@ -0,0 +1,18 @@
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
// Runs a cleanup after each test case
afterEach(() => {
cleanup();
});
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
href: '',
pathname: '/',
reload: vi.fn()
},
writable: true
});

78
src/types.ts Normal file
View File

@ -0,0 +1,78 @@
export interface VesselData {
imo: string;
vesselName: string;
type: string;
length: number;
width: number;
estimatedEnginePower: number;
}
export interface CarbonCalculation {
yearlyEmissions: number;
offsetCost: number;
recommendedProjects: Array<{
id: string;
name: string;
description: string;
costPerTon: number;
}>;
}
export interface CarbonEstimate {
fuelConsumption: number; // liters per year
co2Emissions: number; // tons per year
}
export interface TripEstimate {
distance: number; // nautical miles
duration: number; // hours
fuelConsumption: number; // liters
co2Emissions: number; // tons
}
export interface Currency {
code: string;
symbol: string;
rate: number; // Exchange rate relative to USD
}
export type CurrencyCode = 'USD' | 'EUR' | 'GBP' | 'CHF';
export interface Portfolio {
id: number;
name: string;
description: string;
projects: OffsetProject[];
pricePerTon: number;
currency: CurrencyCode;
}
export interface OffsetProject {
id: string;
name: string;
description: string;
shortDescription: string;
imageUrl: string;
pricePerTon: number;
location: string;
type: string;
verificationStandard: string;
impactMetrics: {
co2Reduced: number;
treesPlanted?: number;
livelihoodsImproved?: number;
};
}
export interface OffsetOrder {
id: string;
amountCharged: number; // Amount in cents
currency: CurrencyCode;
tons: number;
portfolio: Portfolio;
status: 'pending' | 'completed' | 'failed';
createdAt: string;
dryRun: boolean;
}
export type CalculatorType = 'trip' | 'annual';

View File

@ -0,0 +1,88 @@
import { validateEmail, formatEmailContent, sendFormspreeEmail } from '../email';
describe('Email Utilities', () => {
describe('validateEmail', () => {
it('validates correct email addresses', () => {
expect(validateEmail('test@example.com')).toBe(true);
expect(validateEmail('user.name+tag@example.co.uk')).toBe(true);
});
it('rejects invalid email addresses', () => {
expect(validateEmail('not-an-email')).toBe(false);
expect(validateEmail('@example.com')).toBe(false);
expect(validateEmail('test@')).toBe(false);
});
});
describe('formatEmailContent', () => {
const testData = {
name: 'John Doe',
email: 'john@example.com',
phone: '+1234567890',
company: 'Test Corp',
message: 'Test message'
};
it('formats contact email correctly', () => {
const { subject, body } = formatEmailContent(testData, 'contact');
expect(subject).toBe('Contact from John Doe - Puffin Offset');
expect(body).toContain('Name: John Doe');
expect(body).toContain('Email: john@example.com');
});
it('formats offset email correctly', () => {
const { subject, body } = formatEmailContent(testData, 'offset');
expect(subject).toBe('Offset Request - John Doe');
expect(body).toContain('Name: John Doe');
expect(body).toContain('Email: john@example.com');
});
});
describe('sendFormspreeEmail', () => {
const mockFetch = jest.fn();
global.fetch = mockFetch;
beforeEach(() => {
mockFetch.mockClear();
mockFetch.mockImplementation(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({})
}));
});
const testData = {
name: 'John Doe',
email: 'john@example.com',
phone: '+1234567890',
company: 'Test Corp',
message: 'Test message'
};
it('sends contact form to correct Formspree endpoint', async () => {
await sendFormspreeEmail(testData, 'contact');
expect(mockFetch).toHaveBeenCalledWith(
'https://formspree.io/f/xkgovnby',
expect.any(Object)
);
});
it('sends offset form to correct Formspree endpoint', async () => {
await sendFormspreeEmail(testData, 'offset');
expect(mockFetch).toHaveBeenCalledWith(
'https://formspree.io/f/xvgzbory',
expect.any(Object)
);
});
it('handles API errors correctly', async () => {
mockFetch.mockImplementationOnce(() => Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'API Error' })
}));
await expect(sendFormspreeEmail(testData, 'contact'))
.rejects
.toThrow('API Error');
});
});
});

17
src/utils/analytics.ts Normal file
View File

@ -0,0 +1,17 @@
// Simple analytics wrapper
export const analytics = {
pageView(path: string) {
// Send to analytics service when ready
console.log('Page view:', path);
},
event(category: string, action: string, label?: string) {
// Send to analytics service when ready
console.log('Event:', { category, action, label });
},
error(error: Error, context?: string) {
// Send to error tracking service when ready
console.error('Error:', error, context);
}
};

View File

@ -0,0 +1,56 @@
import type { VesselData, CarbonEstimate, TripEstimate } from '../types';
// 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³
export function calculateTripCarbon(
vesselData: VesselData,
distance: number, // nautical miles
speed: number, // knots
fuelRateLitersPerHour: number // liters per hour
): TripEstimate {
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 using the provided formula
// ENM = F(V) * EF / V
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))
};
}
export function calculateCarbonFromFuel(fuelAmount: number, isGallons: boolean = false): number {
// 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));
}
export function calculateCarbonFromDistance(distance: number): number {
// This is a simplified calculation, consider removing or updating based on the new formula
return calculateCarbonFromFuel(distance * 25); // 25 liters per nautical mile is a rough estimate
}

32
src/utils/config.ts Normal file
View File

@ -0,0 +1,32 @@
interface Config {
wrenApiKey: string;
formspreeContactId: string;
formspreeOffsetId: string;
isProduction: boolean;
}
// Load environment variables
const wrenApiKey = import.meta.env.VITE_WREN_API_TOKEN;
const formspreeContactId = import.meta.env.VITE_FORMSPREE_CONTACT_ID;
const formspreeOffsetId = import.meta.env.VITE_FORMSPREE_OFFSET_ID;
// Initialize config
export const config: Config = {
wrenApiKey: wrenApiKey || '',
formspreeContactId: formspreeContactId || 'xkgovnby',
formspreeOffsetId: formspreeOffsetId || 'xvgzbory',
isProduction: import.meta.env.PROD || false
};
// Validate required environment variables
if (!config.wrenApiKey) {
console.error('Missing required environment variable: VITE_WREN_API_TOKEN');
}
// Log config in development
if (!config.isProduction) {
console.log('Config:', {
...config,
wrenApiKey: config.wrenApiKey ? '[REDACTED]' : 'MISSING'
});
}

37
src/utils/currencies.ts Normal file
View File

@ -0,0 +1,37 @@
import type { Currency, CurrencyCode } from '../types';
export const currencies: Record<CurrencyCode, Currency> = {
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 },
};
export function formatCurrency(amountUSD: number, currency: Currency | undefined): string {
if (!currency) {
// Fallback to USD if currency is undefined
currency = currencies.USD;
}
// Convert USD amount to target currency
const convertedAmount = amountUSD * currency.rate;
return `${currency.symbol}${convertedAmount.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})}`;
}
export function getCurrencyByCode(code: string): Currency {
return currencies[code as CurrencyCode] || currencies.USD;
}
// Convert amount from USD to target currency
export function convertFromUSD(amountUSD: number, targetCurrency: Currency): number {
return amountUSD * targetCurrency.rate;
}
// Convert amount to USD from another currency
export function convertToUSD(amount: number, fromCurrency: Currency): number {
return amount / fromCurrency.rate;
}

70
src/utils/email.ts Normal file
View File

@ -0,0 +1,70 @@
import { analytics } from './analytics';
interface EmailData {
name: string;
email: string;
phone?: string;
company?: string;
message: string;
}
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function formatEmailContent(data: EmailData, type: 'contact' | 'offset'): { subject: string, body: string } {
const subject = type === 'contact'
? `Contact from ${data.name} - Puffin Offset`
: `Offset Request - ${data.name}`;
const body = `
Name: ${data.name}
Email: ${data.email}
Phone: ${data.phone || 'Not provided'}
Company: ${data.company || 'Not provided'}
Message:
${data.message}
`.trim();
return { subject, body };
}
export function sendEmail(to: string, subject: string, body: string): void {
const mailtoUrl = `mailto:${to}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = mailtoUrl;
}
export async function sendFormspreeEmail(data: EmailData, type: 'contact' | 'offset'): Promise<void> {
const FORMSPREE_CONTACT_ID = 'xkgovnby'; // Contact form
const FORMSPREE_OFFSET_ID = 'xvgzbory'; // Offset request form
const formId = type === 'contact' ? FORMSPREE_CONTACT_ID : FORMSPREE_OFFSET_ID;
try {
const response = await fetch(`https://formspree.io/f/${formId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
...data,
_subject: type === 'contact'
? `Contact from ${data.name} - Puffin Offset`
: `Offset Request - ${data.name}`
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to send message');
}
analytics.event('email', 'sent', type);
} catch (error) {
analytics.error(error as Error, 'Email sending failed');
throw error;
}
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

74
tailwind.config.js Normal file
View File

@ -0,0 +1,74 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
safelist: [
// Colors
{
pattern: /^(bg|text|border|hover:bg|hover:text)-(blue|gray|green|red|purple|teal|orange|indigo)-(50|100|200|300|400|500|600|700|800|900)/,
},
// Spacing
{
pattern: /^(p|px|py|m|mx|my|mt|mb|ml|mr)-[0-9]+/,
},
// Sizing
{
pattern: /^(w|h)-[0-9]+/,
},
// Layout
{
pattern: /^(min-h|max-w|aspect)-/,
},
// Grid
{
pattern: /^(grid-cols|gap)-/,
},
// Flexbox
{
pattern: /^(flex|items|justify|space|rounded|shadow)/,
},
// Transitions
'transform',
'transition-colors',
'transition-transform',
'duration-300',
'hover:scale-105',
// Interactivity
'cursor-pointer',
'cursor-not-allowed',
'disabled:opacity-50',
'disabled:hover:text-gray-600',
// Typography
'font-semibold',
'font-bold',
'text-center',
'text-left',
// Position
'relative',
'absolute',
'fixed',
'inset-0',
// Display
'object-cover',
'overflow-hidden',
'drop-shadow-lg',
// Animation
'animate-spin',
// Focus
'focus:ring',
'focus:ring-blue-500',
'focus:border-blue-500',
'focus:ring-opacity-50',
'focus:outline-none',
// Forms
'form-input',
'form-select',
'form-textarea'
]
};

24
tsconfig.app.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

19
vite.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
},
});

9
vitest.config.ts Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts']
}
});

159
wordpress-integration.php Normal file
View File

@ -0,0 +1,159 @@
<?php
/**
* Plugin Name: Puffin Offset Calculator Widget
* Description: Embeds the Puffin Offset Calculator into WordPress pages using shortcode
* Version: 1.0.0
* Author: Puffin Offset
*/
// Prevent direct access
if (!defined('ABSPATH')) exit;
class Puffin_Calculator_Widget {
/**
* Initialize the plugin
*/
public function __construct() {
// Register shortcode
add_shortcode('puffin_calculator', array($this, 'render_calculator'));
// Add widget to admin
add_action('widgets_init', array($this, 'register_calculator_widget'));
// Enqueue scripts
add_action('wp_enqueue_scripts', array($this, 'enqueue_scripts'));
}
/**
* Render the calculator via shortcode
*
* @param array $atts Shortcode attributes
* @return string HTML output
*/
public function render_calculator($atts) {
// Default attributes
$attributes = shortcode_atts(array(
'height' => '800px',
'width' => '100%',
'url' => 'http://localhost:8080', // Change to your actual Docker container URL in production
), $atts);
// Generate iframe with responsive wrapper
$output = '<div class="puffin-calculator-wrapper" style="position: relative; overflow: hidden; width: ' . esc_attr($attributes['width']) . ';">';
$output .= '<iframe
src="' . esc_url($attributes['url']) . '"
width="100%"
height="' . esc_attr($attributes['height']) . '"
frameborder="0"
style="border: none; overflow: hidden;"
scrolling="no"
title="Puffin Offset Calculator"
class="puffin-calculator-iframe"
allow="clipboard-read; clipboard-write"
loading="lazy"
></iframe>';
$output .= '</div>';
return $output;
}
/**
* Register the widget
*/
public function register_calculator_widget() {
register_widget('Puffin_Calculator_Widget_Class');
}
/**
* Enqueue needed scripts and styles
*/
public function enqueue_scripts() {
// Enqueue custom styles if needed
wp_enqueue_style(
'puffin-calculator-styles',
plugin_dir_url(__FILE__) . 'css/puffin-calculator.css',
array(),
'1.0.0'
);
// Optional: Enqueue scripts for enhanced interaction between WordPress and iframe
wp_enqueue_script(
'puffin-calculator-scripts',
plugin_dir_url(__FILE__) . 'js/puffin-calculator.js',
array('jquery'),
'1.0.0',
true
);
}
}
/**
* Widget class for the calculator
*/
class Puffin_Calculator_Widget_Class extends WP_Widget {
/**
* Initialize the widget
*/
public function __construct() {
parent::__construct(
'puffin_calculator_widget',
'Puffin Offset Calculator',
array('description' => 'Adds the Puffin Offset Calculator to a widget area')
);
}
/**
* Front-end display of the widget
*/
public function widget($args, $instance) {
echo $args['before_widget'];
if (!empty($instance['title'])) {
echo $args['before_title'] . apply_filters('widget_title', $instance['title']) . $args['after_title'];
}
echo do_shortcode('[puffin_calculator height="' . esc_attr($instance['height'] ?? '800px') . '" url="' . esc_url($instance['url'] ?? 'http://localhost:8080') . '"]');
echo $args['after_widget'];
}
/**
* Back-end widget form
*/
public function form($instance) {
$title = isset($instance['title']) ? $instance['title'] : 'Carbon Offset Calculator';
$height = isset($instance['height']) ? $instance['height'] : '800px';
$url = isset($instance['url']) ? $instance['url'] : 'http://localhost:8080';
?>
<p>
<label for="<?php echo $this->get_field_id('title'); ?>">Title:</label>
<input class="widefat" id="<?php echo $this->get_field_id('title'); ?>" name="<?php echo $this->get_field_name('title'); ?>" type="text" value="<?php echo esc_attr($title); ?>">
</p>
<p>
<label for="<?php echo $this->get_field_id('height'); ?>">Height:</label>
<input class="widefat" id="<?php echo $this->get_field_id('height'); ?>" name="<?php echo $this->get_field_name('height'); ?>" type="text" value="<?php echo esc_attr($height); ?>">
</p>
<p>
<label for="<?php echo $this->get_field_id('url'); ?>">Calculator URL:</label>
<input class="widefat" id="<?php echo $this->get_field_id('url'); ?>" name="<?php echo $this->get_field_name('url'); ?>" type="text" value="<?php echo esc_url($url); ?>">
</p>
<?php
}
/**
* Sanitize widget form values as they are saved
*/
public function update($new_instance, $old_instance) {
$instance = array();
$instance['title'] = (!empty($new_instance['title'])) ? sanitize_text_field($new_instance['title']) : '';
$instance['height'] = (!empty($new_instance['height'])) ? sanitize_text_field($new_instance['height']) : '800px';
$instance['url'] = (!empty($new_instance['url'])) ? esc_url_raw($new_instance['url']) : 'http://localhost:8080';
return $instance;
}
}
// Initialize the plugin
new Puffin_Calculator_Widget();