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:
parent
ab0dbbdb35
commit
01b232f909
102
CLAUDE.md
Normal file
102
CLAUDE.md
Normal 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
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user