React Shipping Calculator
An interactive React component for calculating shipping rates with real-time updates. This example demonstrates how to integrate the Flash Americas API into a React application with a clean, responsive user interface.
Overview
This example includes:
- Real-time rate calculation as users type
- Address validation with error handling
- Responsive design that works on all devices
- Loading states and error handling
- Carrier comparison with visual rate display
- TypeScript support for type safety
Demo
Try the live demo: React Shipping Calculator Demo
Installation
Prerequisites
- Node.js 16+ and npm/yarn
- Flash Americas API key (get one here)
- React 18+ application
Quick Start
Install dependenciesbash
npm install axios react-hook-form @types/react
# or
yarn add axios react-hook-form @types/reactBasic Implementation
1. API Client Setup
First, create an API client for making requests:
src/lib/flashAmericasApi.tstypescript
import axios, { AxiosInstance } from 'axios';
export interface Address {
address1: string;
address2?: string;
city: string;
state: string;
postalCode: string;
country: string;
}
export interface Cargo {
weight: number;
length: number;
width: number;
height: number;
quantity: number;
description: string;
}
export interface QuoteRequest {
originAddress: Address;
destinationAddress: Address;
cargo: Cargo[];
shipDate: string;
}
export interface Rate {
provider: string;
service: string;
totalCost: number;
currency: string;
transitDays: number;
deliveryDate: string;
isGuaranteed: boolean;
breakdown: {
baseCost: number;
fuelSurcharge: number;
accessorials: Record<string, number>;
};
}
export interface QuoteResponse {
quoteId: string;
rates: Rate[];
expiresAt: string;
}
class FlashAmericasAPI {
private client: AxiosInstance;
constructor(apiKey: string) {
this.client = axios.create({
baseURL: 'https://ship.flashamericas.com/api/v1',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
timeout: 10000,
});
}
async getQuotes(request: QuoteRequest): Promise<QuoteResponse> {
const response = await this.client.post('/quotes', request);
return response.data.data;
}
async validateAddress(address: Partial<Address>): Promise<Address> {
const response = await this.client.post('/addresses/validate', address);
return response.data.data;
}
}
export default FlashAmericasAPI;2. Shipping Calculator Component
src/components/ShippingCalculator.tsxtsx
import React, { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import FlashAmericasAPI, { QuoteRequest, Rate, Address, Cargo } from '../lib/flashAmericasApi';
interface FormData {
originAddress: Address;
destinationAddress: Address;
weight: number;
length: number;
width: number;
height: number;
description: string;
}
interface ShippingCalculatorProps {
apiKey: string;
onRateSelect?: (rate: Rate) => void;
}
const ShippingCalculator: React.FC<ShippingCalculatorProps> = ({
apiKey,
onRateSelect
}) => {
const [rates, setRates] = useState<Rate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const api = new FlashAmericasAPI(apiKey);
const { register, handleSubmit, watch, formState: { errors } } = useForm<FormData>({
defaultValues: {
originAddress: {
address1: '',
city: '',
state: '',
postalCode: '',
country: 'US'
},
destinationAddress: {
address1: '',
city: '',
state: '',
postalCode: '',
country: 'US'
},
weight: 1,
length: 12,
width: 12,
height: 12,
description: 'Package'
}
});
const onSubmit = async (data: FormData) => {
setLoading(true);
setError(null);
try {
const quoteRequest: QuoteRequest = {
originAddress: data.originAddress,
destinationAddress: data.destinationAddress,
cargo: [{
weight: data.weight,
length: data.length,
width: data.width,
height: data.height,
quantity: 1,
description: data.description
}],
shipDate: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0]
};
const response = await api.getQuotes(quoteRequest);
setRates(response.rates);
} catch (err: any) {
setError(err.response?.data?.error?.message || 'Failed to get shipping rates');
} finally {
setLoading(false);
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount);
};
return (
<div className="max-w-4xl mx-auto p-6 bg-white rounded-lg shadow-lg">
<h2 className="text-2xl font-bold text-gray-900 mb-6">
Shipping Rate Calculator
</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Origin Address */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">From</h3>
<div className="space-y-4">
<input
{...register('originAddress.address1', { required: 'Address is required' })}
placeholder="Street Address"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{errors.originAddress?.address1 && (
<p className="text-red-500 text-sm">{errors.originAddress.address1.message}</p>
)}
<div className="grid grid-cols-2 gap-2">
<input
{...register('originAddress.city', { required: 'City is required' })}
placeholder="City"
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
{...register('originAddress.state', { required: 'State is required' })}
placeholder="State"
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<input
{...register('originAddress.postalCode', { required: 'ZIP code is required' })}
placeholder="ZIP Code"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Destination Address */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">To</h3>
<div className="space-y-4">
<input
{...register('destinationAddress.address1', { required: 'Address is required' })}
placeholder="Street Address"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<div className="grid grid-cols-2 gap-2">
<input
{...register('destinationAddress.city', { required: 'City is required' })}
placeholder="City"
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
{...register('destinationAddress.state', { required: 'State is required' })}
placeholder="State"
className="px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<input
{...register('destinationAddress.postalCode', { required: 'ZIP code is required' })}
placeholder="ZIP Code"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Package Details */}
<div>
<h3 className="text-lg font-medium text-gray-900 mb-4">Package Details</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Weight (lbs)
</label>
<input
type="number"
step="0.1"
min="0.1"
{...register('weight', { required: 'Weight is required', min: 0.1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Length (in)
</label>
<input
type="number"
step="0.1"
min="0.1"
{...register('length', { required: 'Length is required', min: 0.1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Width (in)
</label>
<input
type="number"
step="0.1"
min="0.1"
{...register('width', { required: 'Width is required', min: 0.1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Height (in)
</label>
<input
type="number"
step="0.1"
min="0.1"
{...register('height', { required: 'Height is required', min: 0.1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 px-6 rounded-md hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Calculating Rates...' : 'Get Shipping Rates'}
</button>
</form>
{/* Error Display */}
{error && (
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-700">{error}</p>
</div>
)}
{/* Rates Display */}
{rates.length > 0 && (
<div className="mt-8">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Available Shipping Options
</h3>
<div className="space-y-3">
{rates.map((rate, index) => (
<div
key={index}
className="p-4 border border-gray-200 rounded-md hover:border-blue-300 cursor-pointer transition-colors"
onClick={() => onRateSelect?.(rate)}
>
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-gray-900">
{rate.provider} - {rate.service}
</h4>
<p className="text-sm text-gray-600">
Delivers in {rate.transitDays} business day{rate.transitDays !== 1 ? 's' : ''}
{rate.isGuaranteed && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
Guaranteed
</span>
)}
</p>
</div>
<div className="text-right">
<p className="text-xl font-bold text-gray-900">
{formatCurrency(rate.totalCost)}
</p>
<p className="text-sm text-gray-600">
by {new Date(rate.deliveryDate).toLocaleDateString()}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default ShippingCalculator;3. Usage in Your App
src/App.tsxtsx
import React from 'react';
import ShippingCalculator from './components/ShippingCalculator';
import './App.css';
function App() {
const handleRateSelect = (rate) => {
console.log('Selected rate:', rate);
// Handle rate selection (e.g., proceed to checkout)
};
return (
<div className="App">
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<h1 className="text-3xl font-bold text-center text-gray-900 mb-8">
Flash Americas Shipping Calculator
</h1>
<ShippingCalculator
apiKey={process.env.REACT_APP_FLASH_AMERICAS_API_KEY!}
onRateSelect={handleRateSelect}
/>
</div>
</div>
</div>
);
}
export default App;Advanced Features
Real-time Validation
Add real-time address validation as users type:
Real-time validation hooktypescript
import { useState, useEffect, useCallback } from 'react';
import { debounce } from 'lodash';
import FlashAmericasAPI, { Address } from '../lib/flashAmericasApi';
export const useAddressValidation = (apiKey: string) => {
const [validationResults, setValidationResults] = useState<Record<string, boolean>>({});
const [validating, setValidating] = useState<Record<string, boolean>>({});
const api = new FlashAmericasAPI(apiKey);
const validateAddress = useCallback(
debounce(async (key: string, address: Partial<Address>) => {
if (!address.city || !address.state || !address.postalCode) return;
setValidating(prev => ({ ...prev, [key]: true }));
try {
await api.validateAddress(address);
setValidationResults(prev => ({ ...prev, [key]: true }));
} catch (error) {
setValidationResults(prev => ({ ...prev, [key]: false }));
} finally {
setValidating(prev => ({ ...prev, [key]: false }));
}
}, 500),
[api]
);
return { validateAddress, validationResults, validating };
};Error Boundary
Wrap your component with an error boundary for better error handling:
src/components/ErrorBoundary.tsxtsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Shipping calculator error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="p-6 bg-red-50 border border-red-200 rounded-md">
<h2 className="text-lg font-medium text-red-800 mb-2">
Something went wrong
</h2>
<p className="text-red-700">
We encountered an error while calculating shipping rates.
Please try again or contact support if the problem persists.
</p>
<button
onClick={() => this.setState({ hasError: false })}
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;Environment Variables
Create a .env file in your project root:
.envbash
REACT_APP_FLASH_AMERICAS_API_KEY=your_api_key_here
REACT_APP_API_URL=https://ship.flashamericas.com/api/v1Testing
Example unit tests for the shipping calculator:
src/components/__tests__/ShippingCalculator.test.tsxtypescript
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import ShippingCalculator from '../ShippingCalculator';
// Mock the API
jest.mock('../lib/flashAmericasApi');
describe('ShippingCalculator', () => {
const mockApiKey = 'test-api-key';
beforeEach(() => {
jest.clearAllMocks();
});
test('renders shipping calculator form', () => {
render(<ShippingCalculator apiKey={mockApiKey} />);
expect(screen.getByText('Shipping Rate Calculator')).toBeInTheDocument();
expect(screen.getByText('From')).toBeInTheDocument();
expect(screen.getByText('To')).toBeInTheDocument();
expect(screen.getByText('Package Details')).toBeInTheDocument();
});
test('validates required fields', async () => {
render(<ShippingCalculator apiKey={mockApiKey} />);
const submitButton = screen.getByText('Get Shipping Rates');
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Address is required')).toBeInTheDocument();
});
});
test('displays rates after successful API call', async () => {
const mockRates = [
{
provider: 'FEDEX',
service: 'FEDEX_GROUND',
totalCost: 12.45,
currency: 'USD',
transitDays: 2,
deliveryDate: '2025-02-22',
isGuaranteed: false,
breakdown: { baseCost: 10.95, fuelSurcharge: 1.50, accessorials: {} }
}
];
// Mock successful API response
const mockApi = require('../lib/flashAmericasApi').default;
mockApi.prototype.getQuotes = jest.fn().mockResolvedValue({
quoteId: 'test-quote-id',
rates: mockRates,
expiresAt: '2025-02-22T10:00:00Z'
});
render(<ShippingCalculator apiKey={mockApiKey} />);
// Fill out form
fireEvent.change(screen.getByPlaceholderText('Street Address'), {
target: { value: '123 Main St' }
});
// ... fill other required fields
fireEvent.click(screen.getByText('Get Shipping Rates'));
await waitFor(() => {
expect(screen.getByText('Available Shipping Options')).toBeInTheDocument();
expect(screen.getByText('FEDEX - FEDEX_GROUND')).toBeInTheDocument();
expect(screen.getByText('$12.45')).toBeInTheDocument();
});
});
});Deployment
Vercel Deployment
Deploy to Vercelbash
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Set environment variables
vercel env add REACT_APP_FLASH_AMERICAS_API_KEYNetlify Deployment
Deploy to Netlifybash
# Build the project
npm run build
# Deploy to Netlify (drag the build folder to netlify.com)
# Or use Netlify CLI
npm install -g netlify-cli
netlify deploy --prod --dir=buildNext Steps
- Add address autocomplete using Google Places API
- Implement carrier logos for better visual appeal
- Add package templates for common shipping sizes
- Integrate with payment processing for complete checkout flow
- Add shipment tracking after booking
Support
- Documentation: Full API Documentation
- GitHub: View complete source code
- Support: Contact our team
