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 [speed, setSpeed] = useState<string>('12');
|
||||||
const [fuelRate, setFuelRate] = useState<string>('100');
|
const [fuelRate, setFuelRate] = useState<string>('100');
|
||||||
const [fuelAmount, setFuelAmount] = useState<string>('');
|
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 [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
|
||||||
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
|
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
|
||||||
const [currency, setCurrency] = useState<CurrencyCode>('USD');
|
const [currency, setCurrency] = useState<CurrencyCode>('USD');
|
||||||
const [offsetPercentage, setOffsetPercentage] = useState<number>(100);
|
|
||||||
const [customPercentage, setCustomPercentage] = useState<string>('');
|
|
||||||
const [customAmount, setCustomAmount] = useState<string>('');
|
const [customAmount, setCustomAmount] = useState<string>('');
|
||||||
const [showOffsetOrder, setShowOffsetOrder] = useState(false);
|
const [showOffsetOrder, setShowOffsetOrder] = useState(false);
|
||||||
const [offsetTons, setOffsetTons] = useState(0);
|
const [offsetTons, setOffsetTons] = useState(0);
|
||||||
@ -35,40 +78,22 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
|||||||
if (calculationType === 'distance') {
|
if (calculationType === 'distance') {
|
||||||
const estimate = calculateTripCarbon(
|
const estimate = calculateTripCarbon(
|
||||||
vesselData,
|
vesselData,
|
||||||
Number(distance),
|
Number(distance.replace(/,/g, '')),
|
||||||
Number(speed),
|
Number(speed.replace(/,/g, '')),
|
||||||
Number(fuelRate)
|
Number(fuelRate.replace(/,/g, ''))
|
||||||
);
|
);
|
||||||
setTripEstimate(estimate);
|
setTripEstimate(estimate);
|
||||||
} else if (calculationType === 'fuel') {
|
} else if (calculationType === 'fuel') {
|
||||||
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
|
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount.replace(/,/g, '')), fuelUnit === 'gallons');
|
||||||
setTripEstimate({
|
setTripEstimate({
|
||||||
distance: 0,
|
distance: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
fuelConsumption: Number(fuelAmount),
|
fuelConsumption: Number(fuelAmount.replace(/,/g, '')),
|
||||||
co2Emissions
|
co2Emissions
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [calculationType, distance, speed, fuelRate, fuelAmount, fuelUnit, vesselData]);
|
}, [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 handleCustomAmountChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
@ -263,10 +288,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
|||||||
Fuel Amount
|
Fuel Amount
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
value={fuelAmount}
|
value={fuelAmount}
|
||||||
onChange={(e) => setFuelAmount(e.target.value)}
|
onChange={handleFuelAmountChange}
|
||||||
placeholder="Enter fuel amount"
|
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"
|
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
|
required
|
||||||
@ -303,10 +327,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
|||||||
Distance (nautical miles)
|
Distance (nautical miles)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
value={distance}
|
value={distance}
|
||||||
onChange={(e) => setDistance(e.target.value)}
|
onChange={handleDistanceChange}
|
||||||
placeholder="Enter distance"
|
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"
|
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
|
required
|
||||||
@ -318,11 +341,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
|||||||
Average Speed (knots)
|
Average Speed (knots)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
max="50"
|
|
||||||
value={speed}
|
value={speed}
|
||||||
onChange={(e) => setSpeed(e.target.value)}
|
onChange={handleSpeedChange}
|
||||||
placeholder="Average speed"
|
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"
|
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
|
required
|
||||||
@ -334,11 +355,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
|||||||
Fuel Rate (liters/hour)
|
Fuel Rate (liters/hour)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value={fuelRate}
|
value={fuelRate}
|
||||||
onChange={(e) => setFuelRate(e.target.value)}
|
onChange={handleFuelRateChange}
|
||||||
placeholder="Fuel consumption rate"
|
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"
|
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
|
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="bg-red-50 p-4 rounded-xl text-center">
|
||||||
<div className="text-3xl font-bold text-red-900">
|
<div className="text-3xl font-bold text-red-900">
|
||||||
{tripEstimate.co2Emissions.toFixed(2)} tons
|
{formatTons(tripEstimate.co2Emissions)} tons
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-red-600">CO₂ Emissions</div>
|
<div className="text-sm text-red-600">CO₂ Emissions</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Offset Options */}
|
{/* Action Button */}
|
||||||
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
||||||
<h3 className="text-lg font-semibold mb-4">Offset Options</h3>
|
<button
|
||||||
|
|
||||||
<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
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOffsetTons(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage));
|
setOffsetTons(tripEstimate.co2Emissions);
|
||||||
setMonetaryAmount(undefined);
|
setMonetaryAmount(undefined);
|
||||||
setShowOffsetOrder(true);
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -48,14 +48,35 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
|
|||||||
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
|
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
|
||||||
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
|
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
|
||||||
const [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
company: '',
|
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(() => {
|
useEffect(() => {
|
||||||
if (!config.wrenApiKey) {
|
if (!config.wrenApiKey) {
|
||||||
setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.');
|
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 {
|
try {
|
||||||
if (config.wrenApiKey) {
|
if (config.wrenApiKey) {
|
||||||
// Try to create real order if API key is available
|
// 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);
|
setOrder(newOrder);
|
||||||
} else {
|
} else {
|
||||||
// Create a mock order for demo purposes
|
// 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),
|
id: 'DEMO-' + Date.now().toString().slice(-8),
|
||||||
amountCharged: Math.round(offsetCost * 100), // Convert to cents
|
amountCharged: Math.round(offsetCost * 100), // Convert to cents
|
||||||
currency: currency,
|
currency: currency,
|
||||||
tons: tons,
|
tons: actualOffsetTons,
|
||||||
portfolio: portfolio,
|
portfolio: portfolio,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@ -126,7 +147,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
|
|||||||
id: 'DEMO-' + Date.now().toString().slice(-8),
|
id: 'DEMO-' + Date.now().toString().slice(-8),
|
||||||
amountCharged: Math.round(offsetCost * 100),
|
amountCharged: Math.round(offsetCost * 100),
|
||||||
currency: currency,
|
currency: currency,
|
||||||
tons: tons,
|
tons: actualOffsetTons,
|
||||||
portfolio: portfolio || {
|
portfolio: portfolio || {
|
||||||
id: 0,
|
id: 0,
|
||||||
name: 'Carbon Offset Portfolio',
|
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 targetCurrency = getCurrencyByCode(currency);
|
||||||
|
|
||||||
const handleInputChange = (field: keyof typeof formData, value: string) => {
|
const handleInputChange = (field: keyof typeof formData, value: string) => {
|
||||||
@ -283,9 +304,66 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
|
|||||||
</div>
|
</div>
|
||||||
) : portfolio ? (
|
) : 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">
|
<div className="bg-white rounded-2xl p-6 shadow-sm">
|
||||||
<h3 className="text-lg font-semibold mb-4">Offset Summary</h3>
|
<h3 className="text-lg font-semibold mb-4">Offset Summary</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center p-4 bg-blue-50 rounded-xl">
|
<div className="flex justify-between items-center p-4 bg-blue-50 rounded-xl">
|
||||||
<div>
|
<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 className="text-sm text-blue-600">From your yacht emissions</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xl font-bold text-blue-900">
|
<div className="text-xl font-bold text-blue-900">
|
||||||
{tons.toFixed(2)} tons
|
{formatTons(actualOffsetTons)} tons
|
||||||
</div>
|
</div>
|
||||||
</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="bg-blue-50 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="font-medium text-blue-900">Total CO₂ Offset</span>
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-blue-900">Portfolio Price</span>
|
<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 [portfolio, setPortfolio] = useState<Portfolio | null>(null);
|
||||||
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
|
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
|
||||||
const [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
|
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({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
company: '',
|
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(() => {
|
useEffect(() => {
|
||||||
if (!config.wrenApiKey) {
|
if (!config.wrenApiKey) {
|
||||||
setError('Carbon offset service is currently unavailable. Please use our contact form to request offsetting.');
|
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 () => {
|
const handleOffsetOrder = async () => {
|
||||||
if (!portfolio) return;
|
if (!portfolio) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newOrder = await createOffsetOrder(portfolio.id, tons);
|
const newOrder = await createOffsetOrder(portfolio.id, actualOffsetTons);
|
||||||
setOrder(newOrder);
|
setOrder(newOrder);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -126,7 +147,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Calculate offset cost using the portfolio price
|
// 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
|
// Robust project click handler with multiple fallbacks
|
||||||
const handleProjectClick = useCallback((project: OffsetProject, e?: React.MouseEvent) => {
|
const handleProjectClick = useCallback((project: OffsetProject, e?: React.MouseEvent) => {
|
||||||
@ -180,7 +201,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
Offset Your Impact
|
Offset Your Impact
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600">
|
<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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@ -354,20 +375,22 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
{portfolio.projects.map((project, index) => (
|
{portfolio.projects.map((project, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={project.id || `project-${index}`}
|
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 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.3, delay: index * 0.1 }}
|
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
>
|
>
|
||||||
{/* Header with title and percentage */}
|
{/* Header with title and percentage - Fixed height for alignment */}
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-4 min-h-[60px]">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-start space-x-3 flex-1 pr-2">
|
||||||
<ProjectTypeIcon project={project} />
|
<div className="mt-1">
|
||||||
<h4 className="font-bold text-gray-900 text-lg">{project.name}</h4>
|
<ProjectTypeIcon project={project} />
|
||||||
|
</div>
|
||||||
|
<h4 className="font-bold text-gray-900 text-lg leading-tight">{project.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
{project.percentage && (
|
{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)}%
|
{(project.percentage * 100).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -385,35 +408,38 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description - This will grow to push price and button to bottom */}
|
||||||
<p className="text-gray-600 mb-4 leading-relaxed">
|
<p className="text-gray-600 mb-4 leading-relaxed flex-grow">
|
||||||
{project.shortDescription || project.description}
|
{project.shortDescription || project.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Price info */}
|
{/* Bottom section - Always aligned at the bottom */}
|
||||||
<div className="bg-gray-50 p-3 rounded-lg mb-4">
|
<div className="mt-auto">
|
||||||
<div className="flex justify-between items-center">
|
{/* Price info */}
|
||||||
<span className="text-gray-600 font-medium">Price per ton:</span>
|
<div className="bg-gray-50 p-3 rounded-lg mb-4">
|
||||||
<span className="text-gray-900 font-bold text-lg">
|
<div className="flex justify-between items-center">
|
||||||
${project.pricePerTon.toFixed(2)}
|
<span className="text-gray-600 font-medium">Price per ton:</span>
|
||||||
</span>
|
<span className="text-gray-900 font-bold text-lg">
|
||||||
|
${project.pricePerTon.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Click button - Primary call to action */}
|
{/* Click button - Primary call to action */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleProjectButtonClick(project);
|
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"
|
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>
|
<span>View Project Details</span>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -432,7 +458,70 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</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"
|
className="bg-gray-50 rounded-lg p-6 mb-6"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@ -442,7 +531,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Amount to Offset:</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Portfolio Distribution:</span>
|
<span className="text-gray-600">Portfolio Distribution:</span>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { Route } from 'lucide-react';
|
import { Leaf } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
|
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
|
||||||
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
|
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
|
||||||
@ -17,11 +17,46 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
const [speed, setSpeed] = useState<string>('12');
|
const [speed, setSpeed] = useState<string>('12');
|
||||||
const [fuelRate, setFuelRate] = useState<string>('100');
|
const [fuelRate, setFuelRate] = useState<string>('100');
|
||||||
const [fuelAmount, setFuelAmount] = useState<string>('');
|
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 [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
|
||||||
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
|
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
|
||||||
const [currency, setCurrency] = useState<CurrencyCode>('USD');
|
const [currency, setCurrency] = useState<CurrencyCode>('USD');
|
||||||
const [offsetPercentage, setOffsetPercentage] = useState<number>(100);
|
|
||||||
const [customPercentage, setCustomPercentage] = useState<string>('');
|
|
||||||
const [customAmount, setCustomAmount] = useState<string>('');
|
const [customAmount, setCustomAmount] = useState<string>('');
|
||||||
|
|
||||||
const handleCalculate = useCallback((e: React.FormEvent) => {
|
const handleCalculate = useCallback((e: React.FormEvent) => {
|
||||||
@ -29,40 +64,19 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
if (calculationType === 'distance') {
|
if (calculationType === 'distance') {
|
||||||
const estimate = calculateTripCarbon(
|
const estimate = calculateTripCarbon(
|
||||||
vesselData,
|
vesselData,
|
||||||
Number(distance),
|
Number(distance.replace(/,/g, '')),
|
||||||
Number(speed),
|
Number(speed.replace(/,/g, '')),
|
||||||
Number(fuelRate)
|
Number(fuelRate.replace(/,/g, ''))
|
||||||
);
|
);
|
||||||
setTripEstimate(estimate);
|
// Immediately navigate to projects page without showing results
|
||||||
|
onOffsetClick?.(estimate.co2Emissions);
|
||||||
} else if (calculationType === 'fuel') {
|
} else if (calculationType === 'fuel') {
|
||||||
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount), fuelUnit === 'gallons');
|
const co2Emissions = calculateCarbonFromFuel(Number(fuelAmount.replace(/,/g, '')), fuelUnit === 'gallons');
|
||||||
setTripEstimate({
|
// Immediately navigate to projects page without showing results
|
||||||
distance: 0,
|
onOffsetClick?.(co2Emissions);
|
||||||
duration: 0,
|
|
||||||
fuelConsumption: Number(fuelAmount),
|
|
||||||
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 handleCustomAmountChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
@ -112,12 +126,12 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
Carbon Calculator
|
Carbon Calculator
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.div
|
<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 }}
|
initial={{ rotate: -10, opacity: 0, scale: 0.8 }}
|
||||||
animate={{ rotate: 0, opacity: 1, scale: 1 }}
|
animate={{ rotate: 0, opacity: 1, scale: 1 }}
|
||||||
transition={{ duration: 0.6, delay: 0.3, type: "spring", stiffness: 300 }}
|
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -225,7 +239,7 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
whileHover={{ scale: 1.03 }}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={{ scale: 0.97 }}
|
whileTap={{ scale: 0.97 }}
|
||||||
>
|
>
|
||||||
Offset Your Impact
|
Calculate your Impact
|
||||||
</motion.button>
|
</motion.button>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@ -251,10 +265,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
<div className="flex space-x-4">
|
<div className="flex space-x-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
value={fuelAmount}
|
value={fuelAmount}
|
||||||
onChange={(e) => setFuelAmount(e.target.value)}
|
onChange={handleFuelAmountChange}
|
||||||
placeholder="Enter amount"
|
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"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
|
||||||
required
|
required
|
||||||
@ -285,10 +298,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
Distance (nautical miles)
|
Distance (nautical miles)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
value={distance}
|
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"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -303,11 +315,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
Average Speed (knots)
|
Average Speed (knots)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
max="50"
|
|
||||||
value={speed}
|
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"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -322,11 +332,9 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
Fuel Consumption Rate (liters per hour)
|
Fuel Consumption Rate (liters per hour)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
value={fuelRate}
|
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"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -411,93 +419,6 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</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>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@ -227,8 +227,53 @@ body {
|
|||||||
|
|
||||||
/* Enhanced gradient overlays */
|
/* Enhanced gradient overlays */
|
||||||
.gradient-luxury {
|
.gradient-luxury {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(135deg,
|
||||||
rgba(15, 23, 42, 0.95) 0%,
|
rgba(15, 23, 42, 0.95) 0%,
|
||||||
rgba(30, 64, 175, 0.85) 50%,
|
rgba(30, 64, 175, 0.85) 50%,
|
||||||
rgba(30, 58, 138, 0.9) 100%);
|
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';
|
import type { VesselData, CarbonEstimate, TripEstimate } from '../types';
|
||||||
|
|
||||||
// Constants for carbon calculations
|
// Constants for carbon calculations (MGO/MDO - Marine Gas Oil / Marine Diesel Oil)
|
||||||
const EMISSION_FACTOR = 3.206; // tons of CO₂ per ton of fuel
|
const EMISSION_FACTOR = 3.206; // tons of CO₂ per ton of fuel (MGO/MDO standard)
|
||||||
const FUEL_DENSITY = 0.85; // tons per m³ (or metric tons per kiloliter)
|
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 GALLONS_TO_LITERS = 3.78541; // 1 US gallon = 3.78541 liters
|
||||||
const LITERS_TO_CUBIC_METERS = 0.001; // 1 liter = 0.001 m³
|
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(
|
export function calculateTripCarbon(
|
||||||
vesselData: VesselData,
|
vesselData: VesselData,
|
||||||
distance: number, // nautical miles
|
distance: number, // nautical miles
|
||||||
@ -13,15 +17,16 @@ export function calculateTripCarbon(
|
|||||||
fuelRateLitersPerHour: number // liters per hour
|
fuelRateLitersPerHour: number // liters per hour
|
||||||
): TripEstimate {
|
): TripEstimate {
|
||||||
const tripHours = distance / speed;
|
const tripHours = distance / speed;
|
||||||
|
|
||||||
// Calculate total fuel consumption in liters
|
// Calculate total fuel consumption in liters
|
||||||
const fuelConsumptionLiters = fuelRateLitersPerHour * tripHours;
|
const fuelConsumptionLiters = fuelRateLitersPerHour * tripHours;
|
||||||
|
|
||||||
// Convert liters to tons for CO₂ calculation
|
// Convert liters to tons for CO₂ calculation
|
||||||
const fuelConsumptionTons = (fuelConsumptionLiters * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY;
|
const fuelConsumptionTons = (fuelConsumptionLiters * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY;
|
||||||
|
|
||||||
// Calculate CO₂ emissions using the provided formula
|
// Calculate CO₂ emissions using per-nautical-mile formula
|
||||||
// ENM = F(V) * EF / V
|
// 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 fuelRateTonsPerHour = (fuelRateLitersPerHour * LITERS_TO_CUBIC_METERS) * FUEL_DENSITY;
|
||||||
const emissionsPerNM = (fuelRateTonsPerHour * EMISSION_FACTOR) / speed;
|
const emissionsPerNM = (fuelRateTonsPerHour * EMISSION_FACTOR) / speed;
|
||||||
const totalEmissions = emissionsPerNM * distance;
|
const totalEmissions = emissionsPerNM * distance;
|
||||||
@ -37,20 +42,16 @@ export function calculateTripCarbon(
|
|||||||
export function calculateCarbonFromFuel(fuelAmount: number, isGallons: boolean = false): number {
|
export function calculateCarbonFromFuel(fuelAmount: number, isGallons: boolean = false): number {
|
||||||
// Convert to liters if input is in gallons
|
// Convert to liters if input is in gallons
|
||||||
const liters = isGallons ? fuelAmount * GALLONS_TO_LITERS : fuelAmount;
|
const liters = isGallons ? fuelAmount * GALLONS_TO_LITERS : fuelAmount;
|
||||||
|
|
||||||
// Convert liters to cubic meters (m³)
|
// Convert liters to cubic meters (m³)
|
||||||
const cubicMeters = liters * LITERS_TO_CUBIC_METERS;
|
const cubicMeters = liters * LITERS_TO_CUBIC_METERS;
|
||||||
|
|
||||||
// Convert volume to mass (tons)
|
// Convert volume to mass (tons)
|
||||||
const fuelTons = cubicMeters * FUEL_DENSITY;
|
const fuelTons = cubicMeters * FUEL_DENSITY;
|
||||||
|
|
||||||
// Calculate CO₂ emissions
|
|
||||||
const co2Emissions = fuelTons * EMISSION_FACTOR;
|
|
||||||
|
|
||||||
return Number(co2Emissions.toFixed(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateCarbonFromDistance(distance: number): number {
|
// Calculate CO₂ emissions: tCO₂ = t_fuel × EF
|
||||||
// This is a simplified calculation, consider removing or updating based on the new formula
|
// This implements the guideline formula: tCO₂ ≈ liters × 0.002693
|
||||||
return calculateCarbonFromFuel(distance * 25); // 25 liters per nautical mile is a rough estimate
|
const co2Emissions = fuelTons * EMISSION_FACTOR;
|
||||||
|
|
||||||
|
return Number(co2Emissions.toFixed(2));
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user