Consolidate environment configuration and remove legacy files
- Merged admin auth variables from root .env into .env.local - Added NocoDB credentials to server/.env for backend webhook integration - Deleted legacy root .env file (superseded by .env.local) - Deleted legacy project/ folder (old Vite app before Next.js migration) Result: Clean 2-file .env structure: - .env.local: Next.js frontend + API routes + admin auth - server/.env: Express backend + Stripe webhooks + NocoDB .env.local, server/.env
This commit is contained in:
parent
e9b79531e1
commit
15ab551f11
@ -1,3 +0,0 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
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.
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
# Version control files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Node.js dependencies
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
|
||||
# Docker files (to avoid recursive copying)
|
||||
Dockerfile
|
||||
Dockerfile.backend
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
|
||||
# Environment files (we'll manage these separately)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor directories and files
|
||||
.vscode
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Testing files
|
||||
coverage
|
||||
**/__tests__
|
||||
**/*.test.*
|
||||
**/*.spec.*
|
||||
|
||||
# Other unnecessary files
|
||||
README.md
|
||||
LICENSE
|
||||
*.md
|
||||
@ -1,3 +0,0 @@
|
||||
VITE_WREN_API_TOKEN=35c025d9-5dbb-404b-85aa-19b09da0578d
|
||||
VITE_FORMSPREE_CONTACT_ID=xkgovnby
|
||||
VITE_FORMSPREE_OFFSET_ID=xvgzbory
|
||||
@ -1,3 +0,0 @@
|
||||
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
project/.gitignore
vendored
24
project/.gitignore
vendored
@ -1,24 +0,0 @@
|
||||
# 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?
|
||||
@ -1,34 +0,0 @@
|
||||
# Build Stage
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the app and build it
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production Stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy the built app from the build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config and environment script
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY env.sh /docker-entrypoint.d/40-env.sh
|
||||
|
||||
# Make the environment script executable
|
||||
RUN chmod +x /docker-entrypoint.d/40-env.sh
|
||||
|
||||
# Create a place for the index.html to include the env-config.js script
|
||||
RUN sed -i '/<head>/a \ <script src="/env-config.js"></script>' /usr/share/nginx/html/index.html || echo "Failed to inject env-config script tag"
|
||||
|
||||
# Expose port 3800 (changed from 80)
|
||||
EXPOSE 3800
|
||||
|
||||
# Start Nginx server (the entrypoint scripts will run first)
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@ -1,27 +0,0 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy only the necessary files for the backend
|
||||
COPY app.js ./
|
||||
COPY src/api ./src/api
|
||||
COPY src/utils ./src/utils
|
||||
COPY src/types.ts ./src/types.ts
|
||||
|
||||
# Create script to handle environment variables
|
||||
RUN echo '#!/bin/sh\n\
|
||||
if [ ! -f .env ] && [ -n "$WREN_API_TOKEN" ]; then\n\
|
||||
echo "Creating .env file from environment variables..."\n\
|
||||
echo "WREN_API_TOKEN=$WREN_API_TOKEN" > .env\n\
|
||||
fi\n\
|
||||
\n\
|
||||
exec "$@"' > /entrypoint.sh \
|
||||
&& chmod +x /entrypoint.sh
|
||||
|
||||
# Use our entrypoint script before running the app
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "app.js"]
|
||||
@ -1,111 +0,0 @@
|
||||
# Puffin Offset - Carbon Offsetting for Yachts
|
||||
|
||||
This application helps users calculate and offset the carbon footprint of yachts through verified carbon offset projects.
|
||||
|
||||
## Features
|
||||
|
||||
- Carbon footprint calculation for yacht trips
|
||||
- Integration with Wren carbon offset projects
|
||||
- Responsive UI for mobile and desktop
|
||||
- Contact forms powered by Formspree
|
||||
|
||||
## Setup
|
||||
|
||||
### Local Development
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Create a `.env` file with your API tokens:
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
3. Run the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Docker Setup
|
||||
|
||||
This project can be run in Docker containers using Docker Compose, and is configured to work with an Nginx reverse proxy on the host.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Nginx (on the host system for SSL termination and reverse proxying)
|
||||
|
||||
### Running with Docker Compose
|
||||
|
||||
1. Build and start the containers:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. The Docker container will listen on port 3800, which should be reverse-proxied by your host Nginx.
|
||||
|
||||
3. Stop the containers:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Nginx Configuration
|
||||
|
||||
The project includes two Nginx configuration files:
|
||||
|
||||
1. `nginx.conf`: Used INSIDE the Docker container to serve the static files on port 3800
|
||||
2. `nginx-host.conf`: A reference config for setting up your Nginx on the HOST to reverse proxy to the Docker container
|
||||
|
||||
To set up the host Nginx:
|
||||
|
||||
1. Copy the nginx-host.conf to your Nginx sites directory:
|
||||
```bash
|
||||
sudo cp nginx-host.conf /etc/nginx/sites-available/puffinoffset.com
|
||||
sudo ln -s /etc/nginx/sites-available/puffinoffset.com /etc/nginx/sites-enabled/
|
||||
```
|
||||
|
||||
2. Uncomment the SSL certificate lines after you've obtained certificates using Certbot or another SSL provider
|
||||
3. Test and reload Nginx:
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
When using Docker, the environment variables are mounted as a volume from your local `.env` file. Make sure it contains:
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
### Backend Service (Optional)
|
||||
|
||||
The docker-compose file includes a commented section for running the backend script (app.js) in a separate container. To enable it:
|
||||
|
||||
1. Uncomment the `backend` service in `docker-compose.yml`
|
||||
2. Ensure your `.env` file contains the needed variables
|
||||
3. Run `docker compose up -d` to start both services
|
||||
|
||||
## API Documentation
|
||||
|
||||
For Wren API documentation, visit: https://wren.co/api
|
||||
|
||||
## Building for Production
|
||||
|
||||
```bash
|
||||
# Without Docker
|
||||
npm run build
|
||||
|
||||
# With Docker
|
||||
docker compose build
|
||||
```
|
||||
|
||||
The production build will be available in the `dist` directory, or served by Nginx in the Docker container.
|
||||
@ -1,104 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
@ -1,9 +0,0 @@
|
||||
# Wren API Tutorial (Node.js)
|
||||
Create a sample offset order using Wren.co's API.
|
||||
wren.co/api
|
||||
|
||||
## Getting started
|
||||
- `npm install`
|
||||
- Create a .env file and enter your Wren API Token: WREN_API_TOKEN=xxx
|
||||
- Run `node app.js` to place a sample offset order for 1 ton of CO2!
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
const fetch = require('node-fetch');
|
||||
const dotenv = require('dotenv');
|
||||
dotenv.config();
|
||||
|
||||
const token = process.env.WREN_API_TOKEN;
|
||||
const url = 'https://www.wren.co/api';
|
||||
|
||||
|
||||
// Retrieve carbon offset options from Wren's portfolio and get the price to offset one ton of
|
||||
// CO2 from the porfolio called 'Community Tree Planting'
|
||||
fetch(`${url}/portfolios`)
|
||||
.then(res => res.json())
|
||||
.then(json => console.log('Portfolios: ', json))
|
||||
|
||||
|
||||
// Based on the response from /portfolios, we know the project we want to use, 'Community
|
||||
// Tree Planting', costs $15.63 per ton of CO2 and has a portfolio ID of 2.
|
||||
|
||||
// Let's create a sample offset order of 1 ton of CO2 from 'Community Tree Planting'!
|
||||
fetch(`${url}/offset-orders`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
portfolioId: 2,
|
||||
tons: 1,
|
||||
dryRun: true
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(json => console.log('Offset Order: ', json))
|
||||
|
||||
|
||||
// Response:
|
||||
// {
|
||||
// dryRun: true,
|
||||
// amountCharged: 1563, (Counted in cents)
|
||||
// currency: 'USD',
|
||||
// tons: 1,
|
||||
// portfolio: {
|
||||
// id: 2,
|
||||
// name: 'Community tree planting',
|
||||
// costPerTon: 15.63,
|
||||
// ...
|
||||
// }
|
||||
// }
|
||||
@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "wren-api-tutorial",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"dotenv": "^8.2.0",
|
||||
"node-fetch": "^2.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
|
||||
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
|
||||
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw=="
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
|
||||
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"dotenv": "^8.2.0",
|
||||
"node-fetch": "^2.6.1"
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and *not* Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
@ -1,9 +0,0 @@
|
||||
# Wren API Tutorial (Web)
|
||||
|
||||
This repo demonstrates how you might use Wren's API to build a web app. This uses vanilla JS, no frameworks, so that you can see how things work without them.
|
||||
|
||||
## Getting started
|
||||
|
||||
- Open index.html in your browser.
|
||||
- Enter your Wren API token on the page.
|
||||
- Use the offset tool!
|
||||
@ -1,80 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Wren API Example</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<p>Enter your API token:</p>
|
||||
<input type="text" id="api-token-input" />
|
||||
|
||||
<p>Choose a portfolio:</p>
|
||||
<select id="portfolio-selector"></select>
|
||||
|
||||
<p>What amount of tons of CO2 would you like to offset?</p>
|
||||
<input type="number" id="tons-input" placeholder="1" />
|
||||
|
||||
<button id="submit-button">Offset!</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_URL = "https://www.wren.co/api";
|
||||
|
||||
async function fetchPortfolios() {
|
||||
const response = await fetch(`${API_URL}/portfolios`);
|
||||
const responseBody = await response.json();
|
||||
return responseBody.portfolios;
|
||||
}
|
||||
|
||||
async function offsetCarbon({ portfolioId, tons, token }) {
|
||||
const request = new Request(`${API_URL}/offset-orders`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tons,
|
||||
portfolioId,
|
||||
dryRun: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await fetch(request);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
const portfolioSelector = document.querySelector("#portfolio-selector");
|
||||
const tonsInput = document.querySelector("#tons-input");
|
||||
const submitButton = document.querySelector("#submit-button");
|
||||
const apiTokenInput = document.querySelector('#api-token-input');
|
||||
|
||||
const portfolios = await fetchPortfolios();
|
||||
|
||||
const options = portfolios.map((portfolio) => {
|
||||
const element = document.createElement("option");
|
||||
element.value = portfolio.id;
|
||||
element.innerHTML = portfolio.name;
|
||||
return element;
|
||||
});
|
||||
|
||||
portfolioSelector.append(...options);
|
||||
|
||||
submitButton.addEventListener("click", async () => {
|
||||
const token = apiTokenInput.value;
|
||||
const tons = parseFloat(tonsInput.value);
|
||||
const portfolioId = portfolioSelector.value;
|
||||
|
||||
if (tons) {
|
||||
const response = await offsetCarbon({ portfolioId, tons, token });
|
||||
alert(`You have offset ${tons} tons of CO2!`);
|
||||
tonsInput.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
boot();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,69 +0,0 @@
|
||||
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();
|
||||
@ -1,29 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3800:3800" # Changed to port 3800 to match external Nginx config
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
# Mount these as volumes to enable hot updating without rebuilding the container
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./.env:/usr/share/nginx/html/.env
|
||||
|
||||
# Optional service for the app.js backend script
|
||||
# Uncomment and configure as needed
|
||||
# backend:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.backend
|
||||
# environment:
|
||||
# - NODE_ENV=production
|
||||
# - WREN_API_TOKEN=${WREN_API_TOKEN}
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - web
|
||||
@ -1,36 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Script to dynamically replace environment variables in the static files
|
||||
# This allows updating env variables without rebuilding the container
|
||||
|
||||
# The directory where the static files are located
|
||||
ROOT_DIR=/usr/share/nginx/html
|
||||
|
||||
# Check if .env file exists
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
echo "Loading environment variables..."
|
||||
|
||||
# Create env-config.js with the environment variables
|
||||
echo "window.env = {" > $ROOT_DIR/env-config.js
|
||||
|
||||
# Extract variables starting with VITE_ and add them to env-config.js
|
||||
grep '^VITE_' $ROOT_DIR/.env | while read -r line; do
|
||||
# Split the line into variable name and value
|
||||
var_name=$(echo $line | cut -d '=' -f1)
|
||||
var_value=$(echo $line | cut -d '=' -f2-)
|
||||
|
||||
# Remove VITE_ prefix for the frontend variable name
|
||||
frontend_var_name=$(echo $var_name | sed 's/^VITE_//')
|
||||
|
||||
# Add the variable to env-config.js
|
||||
echo " $frontend_var_name: \"$var_value\"," >> $ROOT_DIR/env-config.js
|
||||
done
|
||||
|
||||
# Close the JavaScript object
|
||||
echo "};" >> $ROOT_DIR/env-config.js
|
||||
|
||||
echo "Environment variables loaded successfully."
|
||||
fi
|
||||
|
||||
# Execute the command passed to the script (usually start nginx)
|
||||
exec "$@"
|
||||
@ -1,28 +0,0 @@
|
||||
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 },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -1,72 +0,0 @@
|
||||
<!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>
|
||||
@ -1,153 +0,0 @@
|
||||
# /etc/nginx/sites-available/puffinoffset.com
|
||||
|
||||
# 1) Redirect all HTTP to HTTPS, except the ACME challenge path
|
||||
server {
|
||||
listen 80;
|
||||
server_name puffinoffset.com;
|
||||
|
||||
# Allow certbot to do HTTP-01 challenges
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/html; # adjust if your webroot differs
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Redirect everything else to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# 2) HTTPS server block: reverse-proxy to your Docker app on localhost:3800
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name puffinoffset.com;
|
||||
|
||||
# === SSL certs from Let's Encrypt ===
|
||||
# ssl_certificate /etc/letsencrypt/live/puffinoffset.com/fullchain.pem;
|
||||
# ssl_certificate_key /etc/letsencrypt/live/puffinoffset.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # from certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # from certbot
|
||||
|
||||
# === Proxy for direct image requests from Wren API ===
|
||||
location ~* ^/images/(.*)$ {
|
||||
proxy_pass https://www.wren.co/images/$1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host www.wren.co;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffers 16 4k;
|
||||
proxy_buffer_size 2k;
|
||||
|
||||
# Add CORS headers for images
|
||||
add_header Access-Control-Allow-Origin '*' always;
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
|
||||
|
||||
# Cache control for images
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
|
||||
# Handle OPTIONS requests for CORS preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin '*';
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain charset=UTF-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# === Proxy for Wren API requests ===
|
||||
location /api/wren/ {
|
||||
proxy_pass https://www.wren.co/api/;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host www.wren.co;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Add CORS headers for API requests
|
||||
add_header Access-Control-Allow-Origin '*' always;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
|
||||
|
||||
# Handle OPTIONS requests for CORS preflight
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin '*';
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization';
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain charset=UTF-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# === Proxy all other traffic to your Node app ===
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3800;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Add CORS headers for all responses
|
||||
add_header Access-Control-Allow-Origin '*' always;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
|
||||
|
||||
# Increase timeouts for potentially slow API calls
|
||||
proxy_read_timeout 120;
|
||||
proxy_connect_timeout 120;
|
||||
proxy_send_timeout 120;
|
||||
}
|
||||
|
||||
# === Additional common settings ===
|
||||
|
||||
# Increase client body size for file uploads if needed
|
||||
client_max_body_size 10M;
|
||||
|
||||
# Enable compression for better performance
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rss+xml
|
||||
application/vnd.geo+json
|
||||
application/vnd.ms-fontobject
|
||||
application/x-font-ttf
|
||||
application/x-web-app-manifest+json
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/opentype
|
||||
image/bmp
|
||||
image/svg+xml
|
||||
image/x-icon
|
||||
text/cache-manifest
|
||||
text/css
|
||||
text/plain
|
||||
text/vcard
|
||||
text/vnd.rim.location.xloc
|
||||
text/vtt
|
||||
text/x-component
|
||||
text/x-cross-domain-policy;
|
||||
|
||||
# Optional: serve static assets directly if you ever add any here
|
||||
# location /static/ {
|
||||
# root /var/www/puffinoffset.com;
|
||||
# try_files $uri $uri/ =404;
|
||||
# }
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
server {
|
||||
listen 3800; # Changed to port 3800 to match external Nginx config
|
||||
server_name localhost;
|
||||
|
||||
# Root directory for static files
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_min_length 1000;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Add CORS headers for static assets including images
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods 'GET, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Forward all requests to index.html for SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Add CORS headers for all responses
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
|
||||
}
|
||||
|
||||
# Respond to preflighted CORS requests
|
||||
location /api/ {
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header Access-Control-Allow-Origin * always;
|
||||
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
|
||||
add_header Access-Control-Allow-Headers 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization' always;
|
||||
add_header Access-Control-Max-Age 1728000;
|
||||
add_header Content-Type 'text/plain charset=UTF-8';
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Don't cache HTML
|
||||
location ~* \.html$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||
}
|
||||
|
||||
# Error handling
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /index.html;
|
||||
}
|
||||
6593
project/package-lock.json
generated
6593
project/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,41 +0,0 @@
|
||||
{
|
||||
"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",
|
||||
"framer-motion": "^12.15.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"
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@ -1,20 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 910 B |
@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://puffinoffset.com/sitemap.xml
|
||||
@ -1,28 +0,0 @@
|
||||
<?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 +0,0 @@
|
||||
[Binary file content cannot be directly created - please save the provided image as 'yacht-hero.jpg' in the public directory]
|
||||
@ -1,222 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bird, Menu, X } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Home } from './components/Home';
|
||||
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 { analytics } from './utils/analytics';
|
||||
import type { VesselData, CalculatorType } from './types';
|
||||
|
||||
const sampleVessel: VesselData = {
|
||||
imo: "1234567",
|
||||
vesselName: "Sample Yacht",
|
||||
type: "Yacht",
|
||||
length: 50,
|
||||
width: 9,
|
||||
estimatedEnginePower: 2250
|
||||
};
|
||||
|
||||
function App() {
|
||||
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 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 overflow-hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPage + (showOffsetOrder ? '-offset' : '')}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.1, 0.25, 1.0]
|
||||
}}
|
||||
>
|
||||
{renderPage()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</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;
|
||||
@ -1,72 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@ -1,306 +0,0 @@
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios';
|
||||
import type { OffsetOrder, Portfolio } from '../types';
|
||||
import { config } from '../utils/config';
|
||||
|
||||
// Default portfolio for fallback
|
||||
const DEFAULT_PORTFOLIO: Portfolio = {
|
||||
id: 2, // Updated to use ID 2 as in the tutorial
|
||||
name: "Community Tree Planting",
|
||||
description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.",
|
||||
projects: [
|
||||
{
|
||||
id: "tree-1",
|
||||
name: "Community Tree Planting",
|
||||
description: "Carbon sequestration through community tree planting",
|
||||
shortDescription: "Tree planting projects",
|
||||
imageUrl: "https://images.unsplash.com/photo-1513836279014-a89f7a76ae86",
|
||||
pricePerTon: 284.63,
|
||||
location: "Global",
|
||||
type: "Nature Based",
|
||||
verificationStandard: "Gold Standard",
|
||||
impactMetrics: {
|
||||
co2Reduced: 5000
|
||||
}
|
||||
}
|
||||
],
|
||||
pricePerTon: 284.63,
|
||||
currency: 'USD'
|
||||
};
|
||||
|
||||
// Create API client with error handling, timeout, and retry logic
|
||||
const createApiClient = () => {
|
||||
if (!config.wrenApiKey) {
|
||||
console.error('Wren API token is missing! Token:', config.wrenApiKey);
|
||||
console.error('Environment:', window?.env ? JSON.stringify(window.env) : 'No window.env available');
|
||||
throw new Error('Wren API token is not configured');
|
||||
}
|
||||
|
||||
console.log('[wrenClient] Creating API client with key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
|
||||
|
||||
const client = axios.create({
|
||||
// Updated base URL to match the tutorial exactly
|
||||
baseURL: 'https://www.wren.co/api',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.wrenApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 10000, // 10 second timeout
|
||||
validateStatus: (status: number) => 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');
|
||||
}
|
||||
console.log('[wrenClient] Making API request to:', config.url);
|
||||
return config;
|
||||
},
|
||||
(error: Error) => {
|
||||
console.error('[wrenClient] Request configuration error:', error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Add response interceptor for error handling
|
||||
client.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
console.log('[wrenClient] Received API response:', response.status);
|
||||
return response;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
console.warn('[wrenClient] Request timeout, using fallback data');
|
||||
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
|
||||
}
|
||||
if (!error.response) {
|
||||
console.warn('[wrenClient] Network error, using fallback data');
|
||||
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
|
||||
}
|
||||
if (error.response.status === 401) {
|
||||
console.warn('[wrenClient] Authentication failed, using fallback data');
|
||||
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
|
||||
}
|
||||
console.error('[wrenClient] API error:', error.response?.status, error.response?.data);
|
||||
}
|
||||
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('[wrenClient] API Error:', JSON.stringify(errorInfo, null, 2));
|
||||
} else {
|
||||
console.error('[wrenClient] Unknown error:', String(error));
|
||||
}
|
||||
};
|
||||
|
||||
export async function getPortfolios(): Promise<Portfolio[]> {
|
||||
try {
|
||||
if (!config.wrenApiKey) {
|
||||
console.warn('[wrenClient] No Wren API token configured, using fallback portfolio');
|
||||
return [DEFAULT_PORTFOLIO];
|
||||
}
|
||||
|
||||
console.log('[wrenClient] Getting portfolios with token:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
|
||||
|
||||
const api = createApiClient();
|
||||
// Removed the /api prefix to match the working example
|
||||
const response = await api.get('/portfolios');
|
||||
|
||||
if (!response.data?.portfolios?.length) {
|
||||
console.warn('[wrenClient] No portfolios returned from API, using fallback');
|
||||
return [DEFAULT_PORTFOLIO];
|
||||
}
|
||||
|
||||
return response.data.portfolios.map((portfolio: any) => {
|
||||
let pricePerTon = 18; // Default price based on the Wren Climate Fund average
|
||||
|
||||
// The API returns cost_per_ton in snake_case
|
||||
if (portfolio.cost_per_ton !== undefined && portfolio.cost_per_ton !== null) {
|
||||
pricePerTon = typeof portfolio.cost_per_ton === 'number' ? portfolio.cost_per_ton : parseFloat(portfolio.cost_per_ton) || 18;
|
||||
} else if (portfolio.costPerTon !== undefined && portfolio.costPerTon !== null) {
|
||||
pricePerTon = typeof portfolio.costPerTon === 'number' ? portfolio.costPerTon : parseFloat(portfolio.costPerTon) || 18;
|
||||
} else if (portfolio.pricePerTon !== undefined && portfolio.pricePerTon !== null) {
|
||||
pricePerTon = typeof portfolio.pricePerTon === 'number' ? portfolio.pricePerTon : parseFloat(portfolio.pricePerTon) || 18;
|
||||
}
|
||||
|
||||
// Convert from snake_case to camelCase for projects
|
||||
const projects = portfolio.projects?.map(project => {
|
||||
// Ensure cost_per_ton is properly mapped
|
||||
const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null
|
||||
? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton))
|
||||
: pricePerTon;
|
||||
|
||||
// Ensure percentage is properly captured
|
||||
const projectPercentage = project.percentage !== undefined && project.percentage !== null
|
||||
? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: project.id || `project-${Math.random().toString(36).substring(2, 9)}`,
|
||||
name: project.name,
|
||||
description: project.description || '',
|
||||
shortDescription: project.short_description || project.description || '',
|
||||
imageUrl: project.image_url, // Map from snake_case API response
|
||||
pricePerTon: projectPricePerTon,
|
||||
percentage: projectPercentage, // Include percentage field
|
||||
// Remove fields that aren't in the API
|
||||
// The required type fields are still in the type definition for compatibility
|
||||
// but we no longer populate them with default values
|
||||
location: '',
|
||||
type: '',
|
||||
verificationStandard: '',
|
||||
impactMetrics: {
|
||||
co2Reduced: 0
|
||||
}
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return {
|
||||
id: portfolio.id,
|
||||
name: portfolio.name,
|
||||
description: portfolio.description || '',
|
||||
projects: projects,
|
||||
pricePerTon,
|
||||
currency: 'USD'
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
console.warn('[wrenClient] 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) {
|
||||
console.error('[wrenClient] Cannot create order - missing API token');
|
||||
throw new Error('Carbon offset service is currently unavailable. Please contact support.');
|
||||
}
|
||||
|
||||
console.log(`[wrenClient] Creating offset order: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
|
||||
|
||||
const api = createApiClient();
|
||||
// Removed the /api prefix to match the working example
|
||||
const response = await api.post('/offset-orders', {
|
||||
// Using exactly the format shown in the API tutorial
|
||||
portfolioId, // Use the provided portfolio ID instead of hardcoding
|
||||
tons,
|
||||
dryRun // Use the provided dryRun parameter
|
||||
});
|
||||
|
||||
// Add detailed response logging
|
||||
console.log('[wrenClient] Offset order response:',
|
||||
response.status,
|
||||
response.data ? 'has data' : 'no data');
|
||||
|
||||
if (response.status === 400) {
|
||||
console.error('[wrenClient] Bad request details:', response.data);
|
||||
throw new Error(`Failed to create offset order: ${JSON.stringify(response.data)}`);
|
||||
}
|
||||
|
||||
const order = response.data;
|
||||
if (!order) {
|
||||
throw new Error('Empty response received from offset order API');
|
||||
}
|
||||
|
||||
// Log to help diagnose issues
|
||||
console.log('[wrenClient] Order data keys:', Object.keys(order).join(', '));
|
||||
if (order.portfolio) {
|
||||
console.log('[wrenClient] Portfolio data keys:', Object.keys(order.portfolio).join(', '));
|
||||
}
|
||||
|
||||
// Get price from API response which uses cost_per_ton
|
||||
let pricePerTon = 18;
|
||||
if (order.portfolio?.cost_per_ton !== undefined) {
|
||||
pricePerTon = typeof order.portfolio.cost_per_ton === 'number' ? order.portfolio.cost_per_ton : parseFloat(order.portfolio.cost_per_ton) || 18;
|
||||
} else if (order.portfolio?.costPerTon !== undefined) {
|
||||
pricePerTon = typeof order.portfolio.costPerTon === 'number' ? order.portfolio.costPerTon : parseFloat(order.portfolio.costPerTon) || 18;
|
||||
} else if (order.portfolio?.pricePerTon !== undefined) {
|
||||
pricePerTon = typeof order.portfolio.pricePerTon === 'number' ? order.portfolio.pricePerTon : parseFloat(order.portfolio.pricePerTon) || 18;
|
||||
}
|
||||
|
||||
// Create a safe method to extract properties with fallbacks
|
||||
const getSafeProp = (obj: any, prop: string, fallback: any) => {
|
||||
if (!obj) return fallback;
|
||||
return obj[prop] !== undefined ? obj[prop] : fallback;
|
||||
};
|
||||
|
||||
// Use safe accessor to avoid undefined errors
|
||||
const portfolio = order.portfolio || {};
|
||||
|
||||
// Adjusted to use camelCase as per API docs response format
|
||||
return {
|
||||
id: getSafeProp(order, 'id', ''),
|
||||
amountCharged: getSafeProp(order, 'amountCharged', 0),
|
||||
currency: getSafeProp(order, 'currency', 'USD'),
|
||||
tons: getSafeProp(order, 'tons', 0),
|
||||
portfolio: {
|
||||
id: getSafeProp(portfolio, 'id', 2),
|
||||
name: getSafeProp(portfolio, 'name', 'Community Tree Planting'),
|
||||
description: getSafeProp(portfolio, 'description', ''),
|
||||
projects: getSafeProp(portfolio, 'projects', []),
|
||||
pricePerTon,
|
||||
currency: getSafeProp(order, 'currency', 'USD')
|
||||
},
|
||||
status: getSafeProp(order, 'status', ''),
|
||||
createdAt: getSafeProp(order, 'createdAt', new Date().toISOString()),
|
||||
dryRun: getSafeProp(order, 'dryRun', true)
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logError(error);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
console.error('[wrenClient] Axios error details:', {
|
||||
status: axiosError.response?.status,
|
||||
statusText: axiosError.response?.statusText,
|
||||
data: axiosError.response?.data,
|
||||
config: {
|
||||
url: axiosError.config?.url,
|
||||
method: axiosError.config?.method,
|
||||
headers: axiosError.config?.headers ? 'Headers present' : 'No headers',
|
||||
baseURL: axiosError.config?.baseURL,
|
||||
data: axiosError.config?.data
|
||||
}
|
||||
});
|
||||
|
||||
if (axiosError.response?.status === 400) {
|
||||
// Provide more specific error for 400 Bad Request
|
||||
const responseData = axiosError.response.data as any;
|
||||
const errorMessage = responseData?.message || responseData?.error || 'Invalid request format';
|
||||
throw new Error(`Bad request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
if (axiosError.code === 'ECONNABORTED') {
|
||||
throw new Error('Request timed out. Please try again.');
|
||||
}
|
||||
if (!axiosError.response) {
|
||||
throw new Error('Network error. Please check your connection and try again.');
|
||||
}
|
||||
if (axiosError.response.status === 401) {
|
||||
throw new Error('Carbon offset service authentication failed. Please check your API token.');
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to create offset order. Please try again.');
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
// React import removed - not needed with JSX transform
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,208 +0,0 @@
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@ -1,217 +0,0 @@
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
// React import removed - not needed with JSX transform
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
import { 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;
|
||||
}
|
||||
}
|
||||
@ -1,272 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Anchor, Globe, BarChart } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// Animation variants
|
||||
const fadeInUp = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2,
|
||||
delayChildren: 0.3
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scaleOnHover = {
|
||||
rest: { scale: 1 },
|
||||
hover: {
|
||||
scale: 1.05,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 17
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
className="relative mb-16"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<div className="relative h-[500px]">
|
||||
<motion.img
|
||||
initial={{ scale: 1.1, opacity: 0.8 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 2.5, ease: "easeOut" }}
|
||||
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"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5, delay: 0.5 }}
|
||||
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">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.2,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}}
|
||||
className="text-5xl font-bold text-white mb-8 drop-shadow-lg"
|
||||
>
|
||||
Set Sail Sustainably with Carbon Offsetting for Superyachts
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
delay: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}}
|
||||
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.
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-white rounded-xl shadow-lg p-8"
|
||||
variants={fadeInUp}
|
||||
initial="rest"
|
||||
whileInView="rest"
|
||||
viewport={{ once: true }}
|
||||
whileHover="hover"
|
||||
>
|
||||
<motion.div variants={scaleOnHover} className="h-full">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<motion.div
|
||||
initial={{ rotate: -10, opacity: 0 }}
|
||||
whileInView={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Globe className="text-blue-600" size={32} />
|
||||
</motion.div>
|
||||
<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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl shadow-lg p-8"
|
||||
variants={fadeInUp}
|
||||
initial="rest"
|
||||
whileInView="rest"
|
||||
viewport={{ once: true }}
|
||||
whileHover="hover"
|
||||
>
|
||||
<motion.div variants={scaleOnHover} className="h-full">
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<motion.div
|
||||
initial={{ rotate: 10, opacity: 0 }}
|
||||
whileInView={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<BarChart className="text-green-600" size={32} />
|
||||
</motion.div>
|
||||
<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>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-white rounded-xl shadow-lg p-12 mb-16 text-center"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center justify-center space-x-4 mb-6">
|
||||
<motion.div
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
whileInView={{ y: 0, opacity: 1 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 15,
|
||||
delay: 0.2
|
||||
}}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<Anchor className="text-blue-600" size={32} />
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-3xl font-bold text-gray-900"
|
||||
>
|
||||
Empower Your Yacht Business with In-House Offsetting
|
||||
</motion.h2>
|
||||
</div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
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.
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="text-center bg-white rounded-xl shadow-lg p-12 mb-16"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.7, ease: "easeOut" }}
|
||||
viewport={{ once: true, amount: 0.3 }}
|
||||
>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-3xl font-bold text-gray-900 mb-6"
|
||||
>
|
||||
Ready to Make a Difference?
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
viewport={{ once: true }}
|
||||
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.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="flex justify-center space-x-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
onClick={handleCalculateClick}
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-lg"
|
||||
>
|
||||
Calculate Your Impact
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05, backgroundColor: "rgba(219, 234, 254, 1)" }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
onClick={handleLearnMoreClick}
|
||||
className="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-lg"
|
||||
>
|
||||
Learn More
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
// React import removed - not needed with JSX transform
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,567 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind, X } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
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 [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
|
||||
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 allPortfolios = await getPortfolios();
|
||||
|
||||
// Check if portfolios were returned
|
||||
if (!allPortfolios || allPortfolios.length === 0) {
|
||||
throw new Error('No portfolios available');
|
||||
}
|
||||
|
||||
// Only get the puffin portfolio, no selection allowed
|
||||
const puffinPortfolio = allPortfolios.find(p =>
|
||||
p.name.toLowerCase().includes('puffin') ||
|
||||
p.name.toLowerCase().includes('maritime')
|
||||
);
|
||||
|
||||
if (puffinPortfolio) {
|
||||
console.log('[OffsetOrder] Found Puffin portfolio with ID:', puffinPortfolio.id);
|
||||
setPortfolio(puffinPortfolio);
|
||||
} else {
|
||||
// Default to first portfolio if no puffin portfolio found
|
||||
console.log('[OffsetOrder] No Puffin portfolio found, using first available portfolio with ID:', allPortfolios[0].id);
|
||||
setPortfolio(allPortfolios[0]);
|
||||
}
|
||||
} 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 || 18; // Default based on Wren Climate Fund average
|
||||
const targetCurrency = getCurrencyByCode(currency);
|
||||
return formatCurrency(pricePerTon, targetCurrency);
|
||||
} catch (err) {
|
||||
console.error('Error formatting portfolio price:', err);
|
||||
return formatCurrency(18, currencies.USD); // Updated fallback
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate offset cost using the portfolio price
|
||||
const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 18) : 0);
|
||||
|
||||
// Completely simplified project selection handler
|
||||
const handleProjectClick = (project: OffsetProject, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Opening project details for:', project.name);
|
||||
setSelectedProject(project);
|
||||
};
|
||||
|
||||
// Simple lightbox close handler
|
||||
const handleCloseLightbox = () => {
|
||||
console.log('Closing lightbox');
|
||||
setSelectedProject(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full relative"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
<motion.button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 mb-6"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
whileHover={{ x: -5 }}
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="mr-2" size={20} />
|
||||
Back to Calculator
|
||||
</motion.button>
|
||||
|
||||
<motion.div
|
||||
className="text-center mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<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>
|
||||
</motion.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 && (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
{portfolio.projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="bg-gray-50 rounded-lg p-4 hover:shadow-lg transition-all cursor-pointer border border-gray-200 hover:border-blue-300"
|
||||
onClick={(e) => handleProjectClick(project, e)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
MozUserSelect: 'none'
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ProjectTypeIcon project={project} />
|
||||
<h4 className="font-semibold text-gray-900">{project.name}</h4>
|
||||
</div>
|
||||
{project.percentage && (
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
{(project.percentage * 100).toFixed(1)}%
|
||||
</span>
|
||||
)}
|
||||
</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 transition-transform duration-300 hover:scale-110"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
{project.shortDescription || project.description}
|
||||
</p>
|
||||
<div className="space-y-1 text-sm mt-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Price per ton:</span>
|
||||
<span className="text-gray-900 font-medium">
|
||||
${project.pricePerTon.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-center">
|
||||
<span className="text-xs text-blue-600 font-medium">👆 Click for details</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="flex items-center justify-between bg-blue-50 p-4 rounded-lg"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
>
|
||||
<span className="text-blue-900 font-medium">Portfolio Price per Ton:</span>
|
||||
<span className="text-blue-900 font-bold text-lg">
|
||||
{renderPortfolioPrice(portfolio)}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="bg-gray-50 rounded-lg p-6 mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.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="flex justify-between">
|
||||
<span className="text-gray-600">Cost per Ton:</span>
|
||||
<span className="font-medium">{renderPortfolioPrice(portfolio)}</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>
|
||||
</motion.div>
|
||||
|
||||
<motion.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'
|
||||
}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.7 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<Loader2 className="animate-spin mr-2" size={20} />
|
||||
Processing...
|
||||
</div>
|
||||
) : (
|
||||
'Confirm Offset Order'
|
||||
)}
|
||||
</motion.button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Simplified Lightbox Modal */}
|
||||
{selectedProject && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)' }}
|
||||
onClick={handleCloseLightbox}
|
||||
>
|
||||
<div
|
||||
className="relative bg-white rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleCloseLightbox}
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors z-10"
|
||||
aria-label="Close details"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
{/* Project Image */}
|
||||
{selectedProject.imageUrl && (
|
||||
<div className="relative h-64 md:h-80 overflow-hidden rounded-t-lg">
|
||||
<img
|
||||
src={selectedProject.imageUrl}
|
||||
alt={selectedProject.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
<div className="absolute bottom-4 left-4 right-4">
|
||||
<h3 className="text-2xl font-bold text-white mb-2">{selectedProject.name}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ProjectTypeIcon project={selectedProject} />
|
||||
<span className="text-white/90">{selectedProject.type || 'Environmental Project'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Details */}
|
||||
<div className="p-6">
|
||||
{!selectedProject.imageUrl && (
|
||||
<>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">{selectedProject.name}</h3>
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<ProjectTypeIcon project={selectedProject} />
|
||||
<span className="text-gray-600">{selectedProject.type || 'Environmental Project'}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-gray-700 mb-6">
|
||||
{selectedProject.description || selectedProject.shortDescription}
|
||||
</p>
|
||||
|
||||
<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 mb-1">Price per Ton</p>
|
||||
<p className="text-xl font-bold text-gray-900">${selectedProject.pricePerTon.toFixed(2)}</p>
|
||||
</div>
|
||||
{selectedProject.percentage && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-gray-600 mb-1">Portfolio Allocation</p>
|
||||
<p className="text-xl font-bold text-gray-900">{(selectedProject.percentage * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(selectedProject.location || selectedProject.verificationStandard) && (
|
||||
<div className="space-y-3 mb-6">
|
||||
{selectedProject.location && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Location:</span>
|
||||
<span className="font-medium text-gray-900">{selectedProject.location}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedProject.verificationStandard && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">Verification Standard:</span>
|
||||
<span className="font-medium text-gray-900">{selectedProject.verificationStandard}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProject.impactMetrics && selectedProject.impactMetrics.co2Reduced > 0 && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-blue-700 mb-1">Impact Metrics</p>
|
||||
<p className="text-lg font-semibold text-blue-900">
|
||||
{selectedProject.impactMetrics.co2Reduced.toLocaleString()} tons CO₂ reduced
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
// React import removed - not needed with JSX transform
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,498 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Route } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
|
||||
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
|
||||
import { currencies, formatCurrency } 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Animation variants
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.5 }
|
||||
}
|
||||
};
|
||||
|
||||
const slideIn = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.1, 0.25, 1.0]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full mt-8"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">Carbon Offset Calculator</h2>
|
||||
<motion.div
|
||||
initial={{ rotate: -10, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
<Route className="text-blue-500" size={24} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mb-6"
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Calculation Method
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<motion.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'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
Fuel Based
|
||||
</motion.button>
|
||||
<motion.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'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
Distance Based
|
||||
</motion.button>
|
||||
<motion.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'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
Custom Amount
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{calculationType === 'custom' ? (
|
||||
<motion.div
|
||||
key="custom"
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<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 && (
|
||||
<motion.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"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
Offset Your Impact
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.form
|
||||
key="calculator"
|
||||
onSubmit={handleCalculate}
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{calculationType === 'fuel' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{calculationType === 'distance' && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<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
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<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
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Select Currency
|
||||
</label>
|
||||
<div className="max-w-xs">
|
||||
<CurrencySelect value={currency} onChange={setCurrency} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.5,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 17
|
||||
}}
|
||||
>
|
||||
Calculate Impact
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{tripEstimate && calculationType !== 'custom' && (
|
||||
<motion.div
|
||||
className="mt-6 space-y-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
>
|
||||
<motion.div
|
||||
className="grid grid-cols-2 gap-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
>
|
||||
{calculationType === 'distance' && (
|
||||
<motion.div
|
||||
className="bg-gray-50 p-4 rounded-lg"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
className="bg-gray-50 p-4 rounded-lg"
|
||||
initial={{ opacity: 0, x: calculationType === 'distance' ? 20 : 0 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.4 }}
|
||||
>
|
||||
<p className="text-sm text-gray-600">Fuel Consumption</p>
|
||||
<p className="text-xl font-bold text-gray-900">
|
||||
{tripEstimate.fuelConsumption.toLocaleString()} {fuelUnit}
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
>
|
||||
<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, index) => (
|
||||
<motion.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'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.6 + (index * 0.1),
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 17
|
||||
}}
|
||||
>
|
||||
{percent}%
|
||||
</motion.button>
|
||||
))}
|
||||
<motion.div
|
||||
className="flex items-center space-x-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 1.0 }}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="bg-blue-50 p-4 rounded-lg"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 1.1 }}
|
||||
>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
<motion.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"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: 1.2,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 17
|
||||
}}
|
||||
>
|
||||
Offset Your Impact
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
@ -1,18 +0,0 @@
|
||||
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
|
||||
});
|
||||
@ -1,79 +0,0 @@
|
||||
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;
|
||||
percentage?: number; // Added percentage field for project's contribution to portfolio
|
||||
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';
|
||||
@ -1,88 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,17 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
@ -1,56 +0,0 @@
|
||||
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
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
interface Config {
|
||||
wrenApiKey: string;
|
||||
formspreeContactId: string;
|
||||
formspreeOffsetId: string;
|
||||
isProduction: boolean;
|
||||
}
|
||||
|
||||
// Get environment variables either from window.env (for Docker) or import.meta.env (for development)
|
||||
const getEnv = (key: string): string => {
|
||||
// Extract the name without VITE_ prefix
|
||||
const varName = key.replace('VITE_', '');
|
||||
|
||||
// First check if window.env exists and has the variable
|
||||
if (typeof window !== 'undefined' && window.env) {
|
||||
// In Docker, the env.sh script has already removed the VITE_ prefix
|
||||
const envValue = window.env[varName];
|
||||
if (envValue) {
|
||||
console.log(`Using ${varName} from window.env`);
|
||||
return envValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Vite's import.meta.env (for development)
|
||||
// Here we need the full name with VITE_ prefix
|
||||
const value = import.meta.env[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
|
||||
// Load environment variables
|
||||
const wrenApiKey = getEnv('VITE_WREN_API_TOKEN');
|
||||
const formspreeContactId = getEnv('VITE_FORMSPREE_CONTACT_ID');
|
||||
const formspreeOffsetId = getEnv('VITE_FORMSPREE_OFFSET_ID');
|
||||
|
||||
// Initialize config
|
||||
export const config: Config = {
|
||||
wrenApiKey: wrenApiKey || '',
|
||||
formspreeContactId: formspreeContactId || 'xkgovnby',
|
||||
formspreeOffsetId: formspreeOffsetId || 'xvgzbory',
|
||||
isProduction: import.meta.env.PROD === true
|
||||
};
|
||||
|
||||
// Validate required environment variables
|
||||
if (!config.wrenApiKey) {
|
||||
console.error('Missing required environment variable: WREN_API_TOKEN');
|
||||
console.error('Current environment:', window?.env ? JSON.stringify(window.env) : 'No window.env available');
|
||||
}
|
||||
|
||||
// Log config in development
|
||||
if (!config.isProduction) {
|
||||
console.log('Config:', {
|
||||
...config,
|
||||
wrenApiKey: config.wrenApiKey ? '[REDACTED]' : 'MISSING'
|
||||
});
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
25
project/src/vite-env.d.ts
vendored
25
project/src/vite-env.d.ts
vendored
@ -1,25 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_WREN_API_TOKEN: string;
|
||||
readonly VITE_FORMSPREE_CONTACT_ID: string;
|
||||
readonly VITE_FORMSPREE_OFFSET_ID: string;
|
||||
readonly PROD: boolean;
|
||||
readonly DEV: boolean;
|
||||
// Add index signature for dynamic access
|
||||
readonly [key: string]: string | boolean | undefined;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
// Define the environment variable types for the window.env global
|
||||
interface Window {
|
||||
env?: {
|
||||
[key: string]: string | undefined;
|
||||
WREN_API_TOKEN?: string;
|
||||
FORMSPREE_CONTACT_ID?: string;
|
||||
FORMSPREE_OFFSET_ID?: string;
|
||||
};
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
/** @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'
|
||||
]
|
||||
};
|
||||
@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts']
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user