Add MobileOffsetOrder component for CO₂ offset ordering process

This commit is contained in:
Matt 2025-06-05 01:35:18 +02:00
parent 8cc4284140
commit fc828becdc
2 changed files with 400 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCal
import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currencies';
import { CurrencySelect } from './CurrencySelect';
import { PWAInstallPrompt } from './PWAInstallPrompt';
import { MobileOffsetOrder } from './MobileOffsetOrder';
interface Props {
vesselData: VesselData;
@ -25,6 +26,9 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
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);
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
const handleCalculate = useCallback((e: React.FormEvent) => {
e.preventDefault();
@ -79,6 +83,17 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
{ id: 'custom', icon: DollarSign, label: 'Custom Amount', color: 'purple' }
];
if (showOffsetOrder) {
return (
<MobileOffsetOrder
tons={offsetTons}
monetaryAmount={monetaryAmount}
currency={currency}
onBack={() => setShowOffsetOrder(false)}
/>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50">
{/* Mobile Header */}
@ -210,7 +225,11 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
{customAmount && Number(customAmount) > 0 && (
<motion.button
onClick={() => onOffsetClick?.(0, Number(customAmount))}
onClick={() => {
setOffsetTons(0);
setMonetaryAmount(Number(customAmount));
setShowOffsetOrder(true);
}}
className="w-full mt-6 bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
@ -435,7 +454,11 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
</div>
<button
onClick={() => onOffsetClick?.(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage))}
onClick={() => {
setOffsetTons(calculateOffsetAmount(tripEstimate.co2Emissions, offsetPercentage));
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

View File

@ -0,0 +1,375 @@
import React, { useState } from 'react';
import { Check, ArrowLeft, Loader2, CreditCard, User, Mail, Phone } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { CurrencyCode } from '../types';
import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currencies';
interface Props {
tons: number;
monetaryAmount?: number;
currency: CurrencyCode;
onBack: () => void;
}
export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Props) {
const [currentStep, setCurrentStep] = useState<'summary' | 'payment' | 'confirmation'>('summary');
const [loading, setLoading] = useState(false);
const [orderData, setOrderData] = useState({
name: '',
email: '',
phone: ''
});
// Calculate cost (using dummy pricing)
const pricePerTon = 18; // $18 per ton
const totalCost = monetaryAmount || (tons * pricePerTon);
const targetCurrency = getCurrencyByCode(currency);
const handleInputChange = (field: keyof typeof orderData, value: string) => {
setOrderData(prev => ({ ...prev, [field]: value }));
};
const handleProceedToPayment = () => {
setCurrentStep('payment');
};
const handlePlaceOrder = async () => {
setLoading(true);
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
setCurrentStep('confirmation');
};
const generateOrderId = () => {
return 'PO-' + Date.now().toString().slice(-8);
};
const renderSummaryStep = () => (
<motion.div
className="space-y-6"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<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>
<div className="font-semibold text-blue-900">CO to Offset</div>
<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
</div>
</div>
<div className="flex justify-between items-center p-4 bg-green-50 rounded-xl">
<div>
<div className="font-semibold text-green-900">Price per Ton</div>
<div className="text-sm text-green-600">Verified carbon credits</div>
</div>
<div className="text-xl font-bold text-green-900">
{formatCurrency(pricePerTon, targetCurrency)}
</div>
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">Total Cost</span>
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(totalCost, targetCurrency)}
</span>
</div>
</div>
</div>
</div>
<div className="bg-white rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4">Your Details</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User size={16} className="inline mr-2" />
Full Name
</label>
<input
type="text"
value={orderData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Enter your full name"
className="w-full py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail size={16} className="inline mr-2" />
Email Address
</label>
<input
type="email"
value={orderData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="Enter your email"
className="w-full py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone size={16} className="inline mr-2" />
Phone Number (Optional)
</label>
<input
type="tel"
value={orderData.phone}
onChange={(e) => handleInputChange('phone', e.target.value)}
placeholder="Enter your phone number"
className="w-full py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
</div>
<button
onClick={handleProceedToPayment}
disabled={!orderData.name || !orderData.email}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
Proceed to Payment
</button>
</motion.div>
);
const renderPaymentStep = () => (
<motion.div
className="space-y-6"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
>
<div className="bg-white rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<CreditCard size={20} className="mr-2" />
Payment Information
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Card Number
</label>
<input
type="text"
placeholder="1234 5678 9012 3456"
className="w-full py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Expiry
</label>
<input
type="text"
placeholder="MM/YY"
className="w-full py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
CVV
</label>
<input
type="text"
placeholder="123"
className="w-full py-3 px-4 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cardholder Name
</label>
<input
type="text"
value={orderData.name}
className="w-full py-3 px-4 border border-gray-300 rounded-xl bg-gray-50"
readOnly
/>
</div>
</div>
</div>
<div className="bg-gray-50 rounded-2xl p-6">
<h4 className="font-semibold mb-3">Order Summary</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">CO Offset:</span>
<span className="font-medium">{tons.toFixed(2)} tons</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total:</span>
<span className="font-bold text-lg">
{formatCurrency(totalCost, targetCurrency)}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setCurrentStep('summary')}
className="py-3 px-4 border border-gray-300 text-gray-700 rounded-xl font-medium hover:bg-gray-50 transition-colors"
>
Back
</button>
<button
onClick={handlePlaceOrder}
disabled={loading}
className="py-3 px-4 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl font-semibold hover:shadow-lg transition-all disabled:opacity-50"
>
{loading ? (
<div className="flex items-center justify-center">
<Loader2 className="animate-spin mr-2" size={20} />
Processing...
</div>
) : (
'Place Order'
)}
</button>
</div>
</motion.div>
);
const renderConfirmationStep = () => (
<motion.div
className="space-y-6 text-center"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<div className="bg-white rounded-2xl p-8 shadow-sm">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Check className="text-green-500" size={40} />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-4">
Order Confirmed!
</h3>
<p className="text-gray-600 mb-6">
Congratulations! You've successfully offset {tons.toFixed(2)} tons of CO from your yacht emissions.
</p>
<div className="bg-gray-50 rounded-xl p-6 mb-6">
<h4 className="font-semibold mb-4">Order Details</h4>
<div className="space-y-3 text-left">
<div className="flex justify-between">
<span className="text-gray-600">Order ID:</span>
<span className="font-mono font-medium">{generateOrderId()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">CO Offset:</span>
<span className="font-medium">{tons.toFixed(2)} tons</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Amount Paid:</span>
<span className="font-medium">
{formatCurrency(totalCost, targetCurrency)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Email:</span>
<span className="font-medium">{orderData.email}</span>
</div>
</div>
</div>
<div className="bg-blue-50 rounded-xl p-4 mb-6">
<p className="text-sm text-blue-800">
📧 A confirmation email has been sent to {orderData.email} with your certificate and receipt.
</p>
</div>
</div>
<button
onClick={onBack}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 text-white py-4 px-6 rounded-xl font-semibold text-lg shadow-md hover:shadow-lg transition-all"
>
Back to Calculator
</button>
</motion.div>
);
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50">
{/* Mobile Header */}
<motion.header
className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-50"
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center justify-between p-4">
<motion.button
onClick={currentStep === 'confirmation' ? onBack : () => {
if (currentStep === 'payment') {
setCurrentStep('summary');
} else {
onBack();
}
}}
className="p-2 rounded-xl bg-gray-100 hover:bg-gray-200 transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ArrowLeft size={20} />
</motion.button>
<div className="flex items-center space-x-3">
<h1 className="text-lg font-bold text-gray-900">
{currentStep === 'summary' ? 'Order Summary' :
currentStep === 'payment' ? 'Payment' :
'Order Confirmed'}
</h1>
</div>
<div className="w-10"></div> {/* Spacer */}
</div>
{/* Progress Bar */}
{currentStep !== 'confirmation' && (
<div className="px-4 pb-3">
<div className="flex items-center space-x-2">
<div className={`flex-1 h-2 rounded-full ${
currentStep === 'summary' ? 'bg-blue-200' : 'bg-blue-500'
}`} />
<div className={`flex-1 h-2 rounded-full ${
currentStep === 'payment' ? 'bg-blue-500' : 'bg-gray-200'
}`} />
</div>
</div>
)}
</motion.header>
<div className="p-4 pb-20">
<AnimatePresence mode="wait">
{currentStep === 'summary' && renderSummaryStep()}
{currentStep === 'payment' && renderPaymentStep()}
{currentStep === 'confirmation' && renderConfirmationStep()}
</AnimatePresence>
</div>
</div>
);
}