Added lightboxes

This commit is contained in:
Matt 2025-06-03 14:07:33 +02:00
parent 5d0cfdef47
commit 2376205371
6 changed files with 858 additions and 307 deletions

49
package-lock.json generated
View File

@ -10,6 +10,7 @@
"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"
@ -3431,6 +3432,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.15.0.tgz",
"integrity": "sha512-XKg/LnKExdLGugZrDILV7jZjI599785lDIJZLxMiiIFidCsy0a4R2ZEf+Izm67zyOuJgQYTHOmodi7igQsw3vg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.15.0",
"motion-utils": "^12.12.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -4500,6 +4528,21 @@
"integrity": "sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==",
"dev": true
},
"node_modules/motion-dom": {
"version": "12.15.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.15.0.tgz",
"integrity": "sha512-D2ldJgor+2vdcrDtKJw48k3OddXiZN1dDLLWrS8kiHzQdYVruh0IoTwbJBslrnTXIPgFED7PBN2Zbwl7rNqnhA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.12.1"
}
},
"node_modules/motion-utils": {
"version": "12.12.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.12.1.tgz",
"integrity": "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -5928,6 +5971,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@ -13,6 +13,7 @@
"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"
@ -37,4 +38,4 @@
"vite": "^5.4.2",
"vitest": "^1.3.1"
}
}
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Bird, Menu, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Home } from './components/Home';
import { YachtSearch } from './components/YachtSearch';
import { TripCalculator } from './components/TripCalculator';
@ -8,7 +9,7 @@ import { About } from './components/About';
import { Contact } from './components/Contact';
import { OffsetOrder } from './components/OffsetOrder';
import { getVesselData } from './api/aisClient';
import { calculateCarbon } from './utils/carbonCalculator';
import { calculateTripCarbon } from './utils/carbonCalculator';
import { analytics } from './utils/analytics';
import type { VesselData, CarbonCalculation, CalculatorType } from './types';
@ -211,8 +212,21 @@ function App() {
</div>
</header>
<main className="max-w-7xl mx-auto py-8 sm:py-12">
{renderPage()}
<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">
@ -226,4 +240,4 @@ function App() {
);
}
export default App;
export default App;

View File

@ -1,5 +1,6 @@
import React from 'react';
import React, { 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;
@ -20,87 +21,252 @@ export function Home({ onNavigate }: Props) {
}, 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">
<div className="relative mb-16">
<motion.div
className="relative mb-16"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8 }}
>
<div className="relative h-[500px]">
<img
<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"
/>
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/20 via-transparent to-green-500/20" />
<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">
<h1 className="text-5xl font-bold text-white mb-8 drop-shadow-lg">
<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
</h1>
<p className="text-xl text-white max-w-3xl mx-auto leading-relaxed drop-shadow-lg">
</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>
</div>
</div>
</div>
</motion.div>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-16">
<div className="bg-white rounded-xl shadow-lg p-8 transform hover:scale-105 transition-transform duration-300">
<div className="flex items-center space-x-4 mb-6">
<Globe className="text-blue-600" size={32} />
<h2 className="text-2xl font-bold text-gray-900">Flexible Offsetting Solutions</h2>
</div>
<p className="text-gray-600 leading-relaxed text-justify">
With Puffin's carbon offsetting program, it's simple to mitigate the environmental impact of a yacht's use by supporting impactful international projects. Whether you want to offset a portion of a single trip, a season, or a yacht's full annual emissions, Puffin gives you the flexibility to offset as much or as little as you like.
</p>
</div>
<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>
<div className="bg-white rounded-xl shadow-lg p-8 transform hover:scale-105 transition-transform duration-300">
<div className="flex items-center space-x-4 mb-6">
<BarChart className="text-green-600" size={32} />
<h2 className="text-2xl font-bold text-gray-900">Your Values, Your Choice</h2>
</div>
<p className="text-gray-600 leading-relaxed text-justify">
Our portfolios are designed to resonate with the values of our most environmentally-conscious clients, ensuring contributions align with their passion for a better planet. Our science-based, verified carbon offsetting projects have a real and ongoing impact in the fight against climate change.
</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-lg p-12 mb-16 text-center">
<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">
<Anchor className="text-blue-600" size={32} />
<h2 className="text-3xl font-bold text-gray-900">
<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
</h2>
</motion.h2>
</div>
<p className="text-lg text-gray-600 leading-relaxed text-justify">
<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.
</p>
</motion.p>
</div>
</div>
</motion.div>
<div className="text-center bg-white rounded-xl shadow-lg p-12 mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-6">
<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?
</h2>
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
</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.
</p>
<div className="flex justify-center space-x-4">
<button
</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 hover:bg-blue-700 transition-colors"
className="bg-blue-600 text-white px-8 py-3 rounded-lg"
>
Calculate Your Impact
</button>
<button
</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 hover:bg-blue-50 transition-colors"
className="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-lg"
>
Learn More
</button>
</div>
</div>
</motion.button>
</motion.div>
</motion.div>
</div>
);
}
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind } from 'lucide-react';
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind, X } from 'lucide-react';
import { motion, AnimatePresence } 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';
@ -47,6 +48,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
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: '',
@ -127,23 +129,37 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 18) : 0);
return (
<div className="bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full">
<button
<motion.div
className="bg-white rounded-lg shadow-xl p-8 max-w-4xl w-full"
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 }}
>
<ArrowLeft className="mr-2" size={20} />
Back to Calculator
</button>
</motion.button>
<div className="text-center mb-8">
<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>
</div>
</motion.div>
{error && !config.wrenApiKey ? (
<div className="max-w-2xl mx-auto">
@ -306,9 +322,23 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
</p>
{portfolio.projects && portfolio.projects.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{portfolio.projects.map((project) => (
<div key={project.id} className="bg-gray-50 rounded-lg p-4 hover:shadow-md transition-shadow">
<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, index) => (
<motion.div
key={project.id}
className="bg-gray-50 rounded-lg p-4 hover:shadow-lg transition-all cursor-pointer"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 * index }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
onClick={() => setSelectedProject(project)}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center space-x-2">
<ProjectTypeIcon project={project} />
@ -325,7 +355,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
<img
src={project.imageUrl}
alt={project.name}
className="absolute inset-0 w-full h-full object-cover"
className="absolute inset-0 w-full h-full object-cover transition-transform duration-300 hover:scale-110"
/>
</div>
)}
@ -340,35 +370,48 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
</span>
</div>
</div>
</div>
<div className="mt-3 text-center">
<span className="text-xs text-blue-600 font-medium">Click for details</span>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
<div className="flex items-center justify-between bg-blue-50 p-4 rounded-lg">
<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>
</div>
</motion.div>
</div>
<div className="bg-gray-50 rounded-lg p-6 mb-6">
<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="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">
@ -377,14 +420,19 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
</div>
</div>
</div>
</div>
</motion.div>
<button
<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">
@ -394,9 +442,123 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
) : (
'Confirm Offset Order'
)}
</button>
</motion.button>
</>
) : null}
</div>
{/* Project Lightbox Modal */}
<AnimatePresence>
{selectedProject && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setSelectedProject(null)}
>
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black bg-opacity-50"
initial={{ opacity: 0 }}
animate={{ opacity: 0.5 }}
exit={{ opacity: 0 }}
/>
{/* Modal Content */}
<motion.div
className="relative bg-white rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
onClick={(e) => e.stopPropagation()}
>
{/* Close Button */}
<button
onClick={() => setSelectedProject(null)}
className="absolute top-4 right-4 p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors"
>
<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>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@ -1,5 +1,6 @@
import React, { 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';
@ -70,19 +71,59 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
}
}, []);
// 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 (
<div className="bg-white rounded-lg shadow-xl p-6 max-w-2xl w-full mt-8">
<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>
<Route className="text-blue-500" size={24} />
<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>
<div className="mb-6">
<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">
<button
<motion.button
type="button"
onClick={() => setCalculationType('fuel')}
className={`px-4 py-2 rounded-lg transition-colors ${
@ -90,10 +131,13 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
? '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
</button>
<button
</motion.button>
<motion.button
type="button"
onClick={() => setCalculationType('distance')}
className={`px-4 py-2 rounded-lg transition-colors ${
@ -101,10 +145,13 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
? '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
</button>
<button
</motion.button>
<motion.button
type="button"
onClick={() => setCalculationType('custom')}
className={`px-4 py-2 rounded-lg transition-colors ${
@ -112,228 +159,340 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
? '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
</button>
</motion.button>
</div>
</div>
</motion.div>
{calculationType === 'custom' ? (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter Amount to Offset
</label>
<div className="relative rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-500 sm:text-sm">{currencies[currency].symbol}</span>
</div>
<input
type="number"
value={customAmount}
onChange={handleCustomAmountChange}
placeholder="Enter amount"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 pl-7 pr-12 focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
required
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-500 sm:text-sm">{currency}</span>
</div>
</div>
</div>
{customAmount && Number(customAmount) > 0 && (
<button
onClick={() => onOffsetClick?.(0, Number(customAmount))}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors mt-6"
>
Offset Your Impact
</button>
)}
</div>
) : (
<form onSubmit={handleCalculate} className="space-y-4">
{calculationType === 'fuel' && (
<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">
Fuel Consumption
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Currency
</label>
<div className="flex space-x-4">
<div className="flex-1">
<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={fuelAmount}
onChange={(e) => setFuelAmount(e.target.value)}
placeholder="Enter amount"
value={distance}
onChange={(e) => setDistance(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</div>
<div>
<select
value={fuelUnit}
onChange={(e) => setFuelUnit(e.target.value as 'liters' | 'gallons')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</div>
</div>
</div>
)}
</motion.div>
{calculationType === 'distance' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">
Distance (nautical miles)
</label>
<input
type="number"
min="1"
value={distance}
onChange={(e) => setDistance(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Average Speed (knots)
</label>
<input
type="number"
min="1"
max="50"
value={speed}
onChange={(e) => setSpeed(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption Rate (liters per hour)
</label>
<input
type="number"
min="1"
step="1"
value={fuelRate}
onChange={(e) => setFuelRate(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
<p className="mt-1 text-sm text-gray-500">
Typical range: 50 - 500 liters per hour for most yachts
</p>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<button
type="submit"
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors"
>
Calculate Impact
</button>
</form>
)}
{tripEstimate && calculationType !== 'custom' && (
<div className="mt-6 space-y-6">
<div className="grid grid-cols-2 gap-4">
{calculationType === 'distance' && (
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Trip Duration</p>
<p className="text-xl font-bold text-gray-900">
{tripEstimate.duration.toFixed(1)} hours
</p>
</div>
)}
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Fuel Consumption</p>
<p className="text-xl font-bold text-gray-900">
{tripEstimate.fuelConsumption.toLocaleString()} {fuelUnit}
</p>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Offset Percentage
</label>
<div className="flex flex-wrap gap-3 mb-3">
{[100, 75, 50, 25].map((percent) => (
<button
key={percent}
type="button"
onClick={() => handlePresetPercentage(percent)}
className={`px-4 py-2 rounded-lg transition-colors ${
offsetPercentage === percent && customPercentage === ''
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
{percent}%
</button>
))}
<div className="flex items-center space-x-2">
<input
type="number"
value={customPercentage}
onChange={handleCustomPercentageChange}
placeholder="Custom %"
min="0"
max="100"
className="w-24 px-3 py-2 border rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
<span className="text-gray-600">%</span>
</div>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-gray-600">Selected CO Offset</p>
<p className="text-2xl font-bold text-blue-900">
{calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons
</p>
<p className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {tripEstimate.co2Emissions.toFixed(2)} tons
</p>
</div>
<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>
<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"
<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" }}
>
Offset Your Impact
</button>
</div>
)}
</div>
<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>
);
}
}