Enhance UX with number formatting and improve offset workflow

- Add comma-separated number formatting for better readability in all calculator inputs
- Move offset percentage selection from calculator to offset order page for clearer workflow
- Improve project card layout with consistent height alignment in OffsetOrder
- Change number inputs to text inputs to support formatted display
- Update form messages to reflect chosen offset percentage
- Add CLAUDE.md documentation for repository guidance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-10-29 12:51:43 +01:00
parent ab0dbbdb35
commit 01b232f909
7 changed files with 505 additions and 293 deletions

102
CLAUDE.md Normal file
View File

@ -0,0 +1,102 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Core Commands
### Development
```bash
npm run dev # Start Vite dev server at localhost:5173
npm run build # Build production bundle to dist/
npm run preview # Preview production build locally
```
### Testing & Quality
```bash
npm test # Run tests with Vitest
npm run lint # Run ESLint
```
### Docker Operations
```bash
docker compose up -d # Start containerized app on port 3800
docker compose down # Stop containers
docker compose build # Build Docker images
```
## Architecture Overview
### Application Type
React + TypeScript SPA for carbon offsetting calculations for yachts, built with Vite. Features both desktop and mobile-specific routes with Progressive Web App (PWA) support.
### Key Architectural Patterns
**Component Structure**: The app uses a single-page architecture with client-side routing managed through state in `App.tsx`. Navigation changes `currentPage` state rather than using React Router.
**Mobile App Route**: Special `/mobile-app` route renders `MobileCalculator` component exclusively, bypassing the standard layout. This is detected via `window.location.pathname` and managed through `isMobileApp` state.
**Data Flow**:
- Vessel data flows from `App.tsx` → Calculator components → `OffsetOrder`
- Carbon calculations happen in `utils/carbonCalculator.ts`
- API calls go through dedicated clients in `src/api/`
### API Integration
**Wren Climate API** (`src/api/wrenClient.ts`):
- Base URL: `https://www.wren.co/api`
- Requires Bearer token authentication via `VITE_WREN_API_TOKEN`
- Handles portfolio fetching and offset order creation
- Implements retry logic and fallback to default portfolio
**AIS Client** (`src/api/aisClient.ts`):
- Fetches vessel data by IMO number
- Currently uses mock data in development
**Formspree Integration**:
- Contact forms use `VITE_FORMSPREE_CONTACT_ID` and `VITE_FORMSPREE_OFFSET_ID`
- Handled in `Contact.tsx` and offset order components
### State Management
- Local component state via React hooks
- No global state management library
- Form state managed locally within components
- Calculator results passed via props to OffsetOrder components
### Styling Approach
- Tailwind CSS for utility-first styling
- Custom glass morphism effects via `index.css`
- Framer Motion for animations
- Responsive design with mobile-first approach
### Component Responsibilities
**Core Components**:
- `TripCalculator.tsx`: Desktop carbon calculator with trip details form
- `MobileCalculator.tsx`: Mobile-optimized calculator with step-by-step flow
- `OffsetOrder.tsx` / `MobileOffsetOrder.tsx`: Handles offset purchase flow
- `Home.tsx`: Landing page with hero section and navigation
- `Contact.tsx`: Contact form integration
**Calculation Logic** (`utils/carbonCalculator.ts`):
- Implements DEFRA emission factors
- Calculates based on distance, speed, engine power
- Returns tons of CO2 and monetary amounts
### Environment Configuration
Required environment variables in `.env`:
```
VITE_WREN_API_TOKEN= # Wren API authentication
VITE_FORMSPREE_CONTACT_ID= # Contact form endpoint
VITE_FORMSPREE_OFFSET_ID= # Offset order form endpoint
```
### Testing Strategy
- Unit tests using Vitest and React Testing Library
- Test files in `__tests__` directories
- Run with `npm test`
### Build & Deployment
- Production builds output to `dist/`
- Docker deployment uses Nginx to serve static files on port 3800
- Host Nginx reverse proxy configuration available in `nginx-host.conf`
- PWA manifest and service worker for mobile app installation

View File

@ -20,11 +20,54 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
const [speed, setSpeed] = useState<string>('12');
const [fuelRate, setFuelRate] = useState<string>('100');
const [fuelAmount, setFuelAmount] = useState<string>('');
// Format number with commas
const formatNumber = (value: string): string => {
const num = value.replace(/,/g, '');
if (num === '') return '';
const parts = num.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
};
// Format tons for display
const formatTons = (tons: number): string => {
const fixed = tons.toFixed(2);
const parts = fixed.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
};
const handleFuelAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setFuelAmount(formatNumber(value));
}
};
const handleDistanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setDistance(formatNumber(value));
}
};
const handleSpeedChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setSpeed(formatNumber(value));
}
};
const handleFuelRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setFuelRate(formatNumber(value));
}
};
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 [showOffsetOrder, setShowOffsetOrder] = useState(false);
const [offsetTons, setOffsetTons] = useState(0);
@ -35,40 +78,22 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
if (calculationType === 'distance') {
const estimate = calculateTripCarbon(
vesselData,
Number(distance),
Number(speed),
Number(fuelRate)
Number(distance.replace(/,/g, '')),
Number(speed.replace(/,/g, '')),
Number(fuelRate.replace(/,/g, ''))
);
setTripEstimate(estimate);
} else if (calculationType === 'fuel') {
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount.replace(/,/g, '')), fuelUnit === 'gallons');
setTripEstimate({
distance: 0,
duration: 0,
fuelConsumption: Number(fuelAmount),
fuelConsumption: Number(fuelAmount.replace(/,/g, '')),
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;
@ -263,10 +288,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
Fuel Amount
</label>
<input
type="number"
min="1"
type="text"
value={fuelAmount}
onChange={(e) => setFuelAmount(e.target.value)}
onChange={handleFuelAmountChange}
placeholder="Enter fuel amount"
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
@ -303,10 +327,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
Distance (nautical miles)
</label>
<input
type="number"
min="1"
type="text"
value={distance}
onChange={(e) => setDistance(e.target.value)}
onChange={handleDistanceChange}
placeholder="Enter distance"
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
@ -318,11 +341,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
Average Speed (knots)
</label>
<input
type="number"
min="1"
max="50"
type="text"
value={speed}
onChange={(e) => setSpeed(e.target.value)}
onChange={handleSpeedChange}
placeholder="Average speed"
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
@ -334,11 +355,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
Fuel Rate (liters/hour)
</label>
<input
type="number"
min="1"
step="1"
type="text"
value={fuelRate}
onChange={(e) => setFuelRate(e.target.value)}
onChange={handleFuelRateChange}
placeholder="Fuel consumption rate"
className="w-full py-4 px-4 text-lg border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
@ -402,66 +421,23 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
<div className="bg-red-50 p-4 rounded-xl text-center">
<div className="text-3xl font-bold text-red-900">
{tripEstimate.co2Emissions.toFixed(2)} tons
{formatTons(tripEstimate.co2Emissions)} tons
</div>
<div className="text-sm text-red-600">CO Emissions</div>
</div>
</div>
{/* Offset Options */}
{/* Action Button */}
<div className="bg-white rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">Offset Options</h3>
<div className="grid grid-cols-4 gap-2 mb-4">
{[100, 75, 50, 25].map((percent) => (
<button
key={percent}
type="button"
onClick={() => handlePresetPercentage(percent)}
className={`py-3 px-2 rounded-xl font-medium text-sm transition-colors ${
offsetPercentage === percent && customPercentage === ''
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{percent}%
</button>
))}
</div>
<div className="flex items-center space-x-2 mb-4">
<input
type="number"
value={customPercentage}
onChange={handleCustomPercentageChange}
placeholder="Custom %"
min="0"
max="100"
className="flex-1 py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<span className="text-gray-600 font-medium">%</span>
</div>
<div className="bg-blue-50 p-4 rounded-xl mb-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-900">
{calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage).toFixed(2)} tons
</div>
<div className="text-sm text-blue-600">
{offsetPercentage}% of total emissions
</div>
</div>
</div>
<button
<button
onClick={() => {
setOffsetTons(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage));
setOffsetTons(tripEstimate.co2Emissions);
setMonetaryAmount(undefined);
setShowOffsetOrder(true);
}}
className="w-full bg-gradient-to-r from-green-500 to-green-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all"
>
Offset Your Impact
Calculate your Impact
</button>
</div>
</motion.div>

View File

@ -48,14 +48,35 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
const [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100%
// Calculate the actual tons to offset based on percentage
const actualOffsetTons = (tons * offsetPercentage) / 100;
// Format tons for display
const formatTons = (tons: number): string => {
const fixed = tons.toFixed(2);
const parts = fixed.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
};
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: `I would like to offset ${tons.toFixed(2)} tons of CO2 from my yacht's emissions.`
message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 from my yacht's emissions.`
});
// Update form message when percentage changes
useEffect(() => {
setFormData(prev => ({
...prev,
message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 (${offsetPercentage}% of ${formatTons(tons)} tons) from my yacht's emissions.`
}));
}, [offsetPercentage, actualOffsetTons, tons]);
useEffect(() => {
if (!config.wrenApiKey) {
setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.');
@ -101,7 +122,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
try {
if (config.wrenApiKey) {
// Try to create real order if API key is available
const newOrder = await createOffsetOrder(portfolio.id, tons, true); // Using dryRun for demo
const newOrder = await createOffsetOrder(portfolio.id, actualOffsetTons, true); // Using dryRun for demo
setOrder(newOrder);
} else {
// Create a mock order for demo purposes
@ -109,7 +130,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
id: 'DEMO-' + Date.now().toString().slice(-8),
amountCharged: Math.round(offsetCost * 100), // Convert to cents
currency: currency,
tons: tons,
tons: actualOffsetTons,
portfolio: portfolio,
status: 'completed',
createdAt: new Date().toISOString(),
@ -126,7 +147,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
id: 'DEMO-' + Date.now().toString().slice(-8),
amountCharged: Math.round(offsetCost * 100),
currency: currency,
tons: tons,
tons: actualOffsetTons,
portfolio: portfolio || {
id: 0,
name: 'Carbon Offset Portfolio',
@ -158,7 +179,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
}
};
const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 18) : 0);
const offsetCost = monetaryAmount || (portfolio ? actualOffsetTons * (portfolio.pricePerTon || 18) : 0);
const targetCurrency = getCurrencyByCode(currency);
const handleInputChange = (field: keyof typeof formData, value: string) => {
@ -283,9 +304,66 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
</div>
) : portfolio ? (
<>
{/* Offset Percentage Slider */}
<div className="bg-white rounded-2xl p-6 shadow-sm mb-4">
<h3 className="text-lg font-semibold mb-4">Choose Your Offset Amount</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-4">
<span className="text-gray-600">Offset Percentage:</span>
<span className="text-2xl font-bold text-blue-600">{offsetPercentage}%</span>
</div>
<div className="relative">
{/* Tick marks container - positioned below the slider */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 w-full h-2 pointer-events-none flex justify-between items-center">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className="w-[2px] h-2 rounded-full"
style={{
backgroundColor: tick <= offsetPercentage ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.2)'
}}
></div>
))}
</div>
{/* Slider - positioned above tick marks */}
<input
type="range"
min="0"
max="100"
value={offsetPercentage}
onChange={(e) => setOffsetPercentage(Number(e.target.value))}
className="relative z-10 w-full h-3 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${offsetPercentage}%, #e5e7eb ${offsetPercentage}%, #e5e7eb 100%)`
}}
/>
</div>
<div className="relative mt-2 h-4">
<span className="text-xs text-gray-500 absolute left-0">0%</span>
<span className="text-xs text-gray-500 absolute left-1/4 -translate-x-1/2">25%</span>
<span className="text-xs text-gray-500 absolute left-1/2 -translate-x-1/2">50%</span>
<span className="text-xs text-gray-500 absolute left-3/4 -translate-x-1/2">75%</span>
<span className="text-xs text-gray-500 absolute right-0">100%</span>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-gray-700">CO to Offset:</span>
<span className="text-xl font-bold text-blue-900">
{formatTons(actualOffsetTons)} tons
</span>
</div>
<div className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {formatTons(tons)} tons total emissions
</div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">Offset Summary</h3>
<div className="space-y-4">
<div className="flex justify-between items-center p-4 bg-blue-50 rounded-xl">
<div>
@ -293,7 +371,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<div className="text-sm text-blue-600">From your yacht emissions</div>
</div>
<div className="text-xl font-bold text-blue-900">
{tons.toFixed(2)} tons
{formatTons(actualOffsetTons)} tons
</div>
</div>
@ -412,7 +490,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-blue-900">Total CO Offset</span>
<span className="font-bold text-blue-900">{tons.toFixed(2)} tons</span>
<span className="font-bold text-blue-900">{formatTons(tons)} tons</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium text-blue-900">Portfolio Price</span>

View File

@ -49,14 +49,35 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
const [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100%
// Calculate the actual tons to offset based on percentage
const actualOffsetTons = (tons * offsetPercentage) / 100;
// Format tons for display
const formatTons = (tons: number): string => {
const fixed = tons.toFixed(2);
const parts = fixed.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
};
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.`
message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 from my yacht's ${calculatorType} emissions.`
});
// Update form message when percentage changes
useEffect(() => {
setFormData(prev => ({
...prev,
message: `I would like to offset ${formatTons(actualOffsetTons)} tons of CO2 (${offsetPercentage}% of ${formatTons(tons)} tons) from my yacht's ${calculatorType} emissions.`
}));
}, [offsetPercentage, actualOffsetTons, tons, calculatorType]);
useEffect(() => {
if (!config.wrenApiKey) {
setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.');
@ -98,12 +119,12 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
const handleOffsetOrder = async () => {
if (!portfolio) return;
setLoading(true);
setError(null);
try {
const newOrder = await createOffsetOrder(portfolio.id, tons);
const newOrder = await createOffsetOrder(portfolio.id, actualOffsetTons);
setOrder(newOrder);
setSuccess(true);
} catch (err) {
@ -126,7 +147,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
};
// Calculate offset cost using the portfolio price
const offsetCost = monetaryAmount || (portfolio ? tons * (portfolio.pricePerTon || 18) : 0);
const offsetCost = monetaryAmount || (portfolio ? actualOffsetTons * (portfolio.pricePerTon || 18) : 0);
// Robust project click handler with multiple fallbacks
const handleProjectClick = useCallback((project: OffsetProject, e?: React.MouseEvent) => {
@ -180,7 +201,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
Offset Your Impact
</h2>
<p className="text-lg text-gray-600">
You're about to offset {tons.toFixed(2)} tons of CO
You're about to offset {formatTons(tons)} tons of CO
</p>
</motion.div>
@ -354,20 +375,22 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
{portfolio.projects.map((project, index) => (
<motion.div
key={project.id || `project-${index}`}
className="bg-white rounded-lg p-6 shadow-md hover:shadow-xl transition-all border border-gray-200 hover:border-blue-400 relative group"
className="bg-white rounded-lg p-6 shadow-md hover:shadow-xl transition-all border border-gray-200 hover:border-blue-400 relative group flex flex-col h-full"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
{/* Header with title and percentage */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<ProjectTypeIcon project={project} />
<h4 className="font-bold text-gray-900 text-lg">{project.name}</h4>
{/* Header with title and percentage - Fixed height for alignment */}
<div className="flex items-start justify-between mb-4 min-h-[60px]">
<div className="flex items-start space-x-3 flex-1 pr-2">
<div className="mt-1">
<ProjectTypeIcon project={project} />
</div>
<h4 className="font-bold text-gray-900 text-lg leading-tight">{project.name}</h4>
</div>
{project.percentage && (
<span className="text-sm bg-blue-100 text-blue-800 font-medium px-3 py-1 rounded-full">
<span className="text-sm bg-blue-100 text-blue-800 font-medium px-3 py-1 rounded-full flex-shrink-0">
{(project.percentage * 100).toFixed(1)}%
</span>
)}
@ -385,35 +408,38 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
</div>
)}
{/* Description */}
<p className="text-gray-600 mb-4 leading-relaxed">
{/* Description - This will grow to push price and button to bottom */}
<p className="text-gray-600 mb-4 leading-relaxed flex-grow">
{project.shortDescription || project.description}
</p>
{/* Price info */}
<div className="bg-gray-50 p-3 rounded-lg mb-4">
<div className="flex justify-between items-center">
<span className="text-gray-600 font-medium">Price per ton:</span>
<span className="text-gray-900 font-bold text-lg">
${project.pricePerTon.toFixed(2)}
</span>
{/* Bottom section - Always aligned at the bottom */}
<div className="mt-auto">
{/* Price info */}
<div className="bg-gray-50 p-3 rounded-lg mb-4">
<div className="flex justify-between items-center">
<span className="text-gray-600 font-medium">Price per ton:</span>
<span className="text-gray-900 font-bold text-lg">
${project.pricePerTon.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Click button - Primary call to action */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleProjectButtonClick(project);
}}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center space-x-2"
>
<span>View Project Details</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
{/* Click button - Primary call to action */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleProjectButtonClick(project);
}}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center space-x-2"
>
<span>View Project Details</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</motion.div>
))}
</motion.div>
@ -432,7 +458,70 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
</motion.div>
</div>
<motion.div
{/* Offset Percentage Slider */}
<motion.div
className="bg-white border rounded-lg p-6 mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.55 }}
>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Choose Your Offset Amount</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-4">
<span className="text-gray-600">Offset Percentage:</span>
<span className="text-2xl font-bold text-blue-600">{offsetPercentage}%</span>
</div>
<div className="relative">
{/* Tick marks - visible notches */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 w-full h-2 pointer-events-none flex justify-between items-center">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className="w-[2px] h-2 rounded-full"
style={{
backgroundColor: tick <= offsetPercentage ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.2)'
}}
></div>
))}
</div>
{/* Slider */}
<input
type="range"
min="0"
max="100"
value={offsetPercentage}
onChange={(e) => setOffsetPercentage(Number(e.target.value))}
className="relative z-10 w-full h-3 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${offsetPercentage}%, #e5e7eb ${offsetPercentage}%, #e5e7eb 100%)`
}}
/>
</div>
{/* Percentage labels aligned with tick marks */}
<div className="relative mt-2 h-4">
<span className="text-xs text-gray-500 absolute left-0">0%</span>
<span className="text-xs text-gray-500 absolute left-1/4 -translate-x-1/2">25%</span>
<span className="text-xs text-gray-500 absolute left-1/2 -translate-x-1/2">50%</span>
<span className="text-xs text-gray-500 absolute left-3/4 -translate-x-1/2">75%</span>
<span className="text-xs text-gray-500 absolute right-0">100%</span>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-gray-700">CO to Offset:</span>
<span className="text-xl font-bold text-blue-900">
{formatTons(actualOffsetTons)} tons
</span>
</div>
<div className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {formatTons(tons)} tons total emissions
</div>
</div>
</div>
</motion.div>
<motion.div
className="bg-gray-50 rounded-lg p-6 mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -442,7 +531,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
<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>
<span className="font-medium">{formatTons(actualOffsetTons)} tons CO</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Portfolio Distribution:</span>

View File

@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { Route } from 'lucide-react';
import { Leaf } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
@ -17,11 +17,46 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
const [speed, setSpeed] = useState<string>('12');
const [fuelRate, setFuelRate] = useState<string>('100');
const [fuelAmount, setFuelAmount] = useState<string>('');
// Format number with commas
const formatNumber = (value: string): string => {
const num = value.replace(/,/g, '');
if (num === '') return '';
const parts = num.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
};
const handleFuelAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setFuelAmount(formatNumber(value));
}
};
const handleDistanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setDistance(formatNumber(value));
}
};
const handleSpeedChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setSpeed(formatNumber(value));
}
};
const handleFuelRateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/,/g, '');
if (value === '' || /^\d*\.?\d*$/.test(value)) {
setFuelRate(formatNumber(value));
}
};
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) => {
@ -29,40 +64,19 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
if (calculationType === 'distance') {
const estimate = calculateTripCarbon(
vesselData,
Number(distance),
Number(speed),
Number(fuelRate)
Number(distance.replace(/,/g, '')),
Number(speed.replace(/,/g, '')),
Number(fuelRate.replace(/,/g, ''))
);
setTripEstimate(estimate);
// Immediately navigate to projects page without showing results
onOffsetClick?.(estimate.co2Emissions);
} else if (calculationType === 'fuel') {
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
setTripEstimate({
distance: 0,
duration: 0,
fuelConsumption: Number(fuelAmount),
co2Emissions
});
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount.replace(/,/g, '')), fuelUnit === 'gallons');
// Immediately navigate to projects page without showing results
onOffsetClick?.(co2Emissions);
}
}, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]);
}, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData, onOffsetClick]);
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;
@ -112,12 +126,12 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
Carbon Calculator
</motion.h2>
<motion.div
className="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center"
className="w-12 h-12 bg-gradient-to-br from-green-500 to-green-600 rounded-full flex items-center justify-center"
initial={{ rotate: -10, opacity: 0, scale: 0.8 }}
animate={{ rotate: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.6, delay: 0.3, type: "spring", stiffness: 300 }}
>
<Route className="text-white" size={20} />
<Leaf className="text-white" size={20} />
</motion.div>
</div>
@ -225,7 +239,7 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
Offset Your Impact
Calculate your Impact
</motion.button>
)}
</motion.div>
@ -251,10 +265,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
<div className="flex space-x-4">
<div className="flex-1">
<input
type="number"
min="1"
type="text"
value={fuelAmount}
onChange={(e) => setFuelAmount(e.target.value)}
onChange={handleFuelAmountChange}
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
@ -285,10 +298,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
Distance (nautical miles)
</label>
<input
type="number"
min="1"
type="text"
value={distance}
onChange={(e) => setDistance(e.target.value)}
onChange={handleDistanceChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
@ -303,11 +315,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
Average Speed (knots)
</label>
<input
type="number"
min="1"
max="50"
type="text"
value={speed}
onChange={(e) => setSpeed(e.target.value)}
onChange={handleSpeedChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
@ -322,11 +332,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
Fuel Consumption Rate (liters per hour)
</label>
<input
type="number"
min="1"
step="1"
type="text"
value={fuelRate}
onChange={(e) => setFuelRate(e.target.value)}
onChange={handleFuelRateChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
@ -411,93 +419,6 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
</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>

View File

@ -227,8 +227,53 @@ body {
/* Enhanced gradient overlays */
.gradient-luxury {
background: linear-gradient(135deg,
rgba(15, 23, 42, 0.95) 0%,
rgba(30, 64, 175, 0.85) 50%,
background: linear-gradient(135deg,
rgba(15, 23, 42, 0.95) 0%,
rgba(30, 64, 175, 0.85) 50%,
rgba(30, 58, 138, 0.9) 100%);
}
/* Custom Range Slider Styles */
input[type="range"].slider {
-webkit-appearance: none;
width: 100%;
height: 12px;
border-radius: 6px;
background: #e5e7eb;
outline: none;
opacity: 1;
transition: opacity 0.2s;
}
input[type="range"].slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition: transform 0.2s, box-shadow 0.2s;
}
input[type="range"].slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition: transform 0.2s, box-shadow 0.2s;
border: none;
}
input[type="range"].slider:hover::-webkit-slider-thumb {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}
input[type="range"].slider:hover::-moz-range-thumb {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}

View File

@ -1,11 +1,15 @@
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)
// Constants for carbon calculations (MGO/MDO - Marine Gas Oil / Marine Diesel Oil)
const EMISSION_FACTOR = 3.206; // tons of CO₂ per ton of fuel (MGO/MDO standard)
const FUEL_DENSITY = 0.84; // kg per liter (typical MGO density, range 0.82-0.86 kg/L)
const GALLONS_TO_LITERS = 3.78541; // 1 US gallon = 3.78541 liters
const LITERS_TO_CUBIC_METERS = 0.001; // 1 liter = 0.001 m³
// Shortcut constant: kg CO₂ per liter = density × EF
// 0.84 kg/L × 3.206 = 2.693 kg CO₂/L = 0.002693 tons CO₂/L
const CO2_PER_LITER = 0.002693; // tons of CO₂ per liter of MGO/MDO
export function calculateTripCarbon(
vesselData: VesselData,
distance: number, // nautical miles
@ -13,15 +17,16 @@ export function calculateTripCarbon(
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
// Calculate CO₂ emissions using per-nautical-mile formula
// Guideline formula: tCO₂ per nm ≈ (LPH × 0.002693) / V
// Implementation: tCO₂ per nm = (LPH × density × EF / 1000) / speed
const fuelRateTonsPerHour = (fuelRateLitersPerHour * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY;
const emissionsPerNM = (fuelRateTonsPerHour * EMISSION_FACTOR) / speed;
const totalEmissions = emissionsPerNM * distance;
@ -37,20 +42,16 @@ export function calculateTripCarbon(
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
// Calculate CO₂ emissions: tCO₂ = t_fuel × EF
// This implements the guideline formula: tCO₂ ≈ liters × 0.002693
const co2Emissions = fuelTons * EMISSION_FACTOR;
return Number(co2Emissions.toFixed(2));
}