360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
|
import { X, Copy, Check, ExternalLink } from 'lucide-react';
|
||
|
|
import { useState } from 'react';
|
||
|
|
import { OrderRecord } from '@/src/types';
|
||
|
|
|
||
|
|
interface OrderDetailsModalProps {
|
||
|
|
order: OrderRecord | null;
|
||
|
|
isOpen: boolean;
|
||
|
|
onClose: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function OrderDetailsModal({ order, isOpen, onClose }: OrderDetailsModalProps) {
|
||
|
|
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||
|
|
|
||
|
|
if (!order) return null;
|
||
|
|
|
||
|
|
const copyToClipboard = async (text: string, fieldName: string) => {
|
||
|
|
try {
|
||
|
|
await navigator.clipboard.writeText(text);
|
||
|
|
setCopiedField(fieldName);
|
||
|
|
setTimeout(() => setCopiedField(null), 2000);
|
||
|
|
} catch (err) {
|
||
|
|
console.error('Failed to copy:', err);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatDate = (dateString: string | undefined) => {
|
||
|
|
if (!dateString) return 'N/A';
|
||
|
|
return new Date(dateString).toLocaleString('en-US', {
|
||
|
|
year: 'numeric',
|
||
|
|
month: 'long',
|
||
|
|
day: 'numeric',
|
||
|
|
hour: '2-digit',
|
||
|
|
minute: '2-digit',
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatCurrency = (amount: string | undefined, currency: string = 'USD') => {
|
||
|
|
if (!amount) return 'N/A';
|
||
|
|
const numAmount = parseFloat(amount) / 100;
|
||
|
|
return new Intl.NumberFormat('en-US', {
|
||
|
|
style: 'currency',
|
||
|
|
currency: currency,
|
||
|
|
}).format(numAmount);
|
||
|
|
};
|
||
|
|
|
||
|
|
const getStatusBadgeClass = (status: string) => {
|
||
|
|
switch (status) {
|
||
|
|
case 'pending':
|
||
|
|
return 'bg-gradient-to-r from-muted-gold/20 to-orange-400/20 text-muted-gold border border-muted-gold/30';
|
||
|
|
case 'paid':
|
||
|
|
return 'bg-gradient-to-r from-maritime-teal/20 to-teal-400/20 text-maritime-teal border border-maritime-teal/30';
|
||
|
|
case 'fulfilled':
|
||
|
|
return 'bg-gradient-to-r from-sea-green/20 to-green-400/20 text-sea-green border border-sea-green/30';
|
||
|
|
case 'cancelled':
|
||
|
|
return 'bg-gradient-to-r from-red-500/20 to-red-400/20 text-red-600 border border-red-500/30';
|
||
|
|
default:
|
||
|
|
return 'bg-gray-100 text-gray-700 border border-gray-300';
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => copyToClipboard(text, fieldName)}
|
||
|
|
className="ml-2 p-1 hover:bg-gray-100 rounded transition-colors"
|
||
|
|
title="Copy to clipboard"
|
||
|
|
>
|
||
|
|
{copiedField === fieldName ? (
|
||
|
|
<Check className="w-4 h-4 text-green-600" />
|
||
|
|
) : (
|
||
|
|
<Copy className="w-4 h-4 text-deep-sea-blue/40" />
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
|
||
|
|
const DetailField = ({
|
||
|
|
label,
|
||
|
|
value,
|
||
|
|
copyable = false,
|
||
|
|
fieldName = '',
|
||
|
|
}: {
|
||
|
|
label: string;
|
||
|
|
value: string | number | undefined | null;
|
||
|
|
copyable?: boolean;
|
||
|
|
fieldName?: string;
|
||
|
|
}) => {
|
||
|
|
const displayValue = value || 'N/A';
|
||
|
|
return (
|
||
|
|
<div className="py-3 border-b border-gray-100 last:border-0">
|
||
|
|
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">{label}</div>
|
||
|
|
<div className="flex items-center">
|
||
|
|
<div className="text-sm text-deep-sea-blue font-medium">{displayValue}</div>
|
||
|
|
{copyable && value && <CopyButton text={String(value)} fieldName={fieldName} />}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
const SectionHeader = ({ title }: { title: string }) => (
|
||
|
|
<h3 className="text-lg font-bold text-deep-sea-blue mb-4 flex items-center">
|
||
|
|
<div className="w-1 h-6 bg-deep-sea-blue rounded-full mr-3"></div>
|
||
|
|
{title}
|
||
|
|
</h3>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AnimatePresence>
|
||
|
|
{isOpen && (
|
||
|
|
<>
|
||
|
|
{/* Backdrop */}
|
||
|
|
<motion.div
|
||
|
|
initial={{ opacity: 0 }}
|
||
|
|
animate={{ opacity: 1 }}
|
||
|
|
exit={{ opacity: 0 }}
|
||
|
|
onClick={onClose}
|
||
|
|
className="fixed inset-0 bg-black/50 z-40"
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Sliding Panel */}
|
||
|
|
<motion.div
|
||
|
|
initial={{ x: '100%' }}
|
||
|
|
animate={{ x: 0 }}
|
||
|
|
exit={{ x: '100%' }}
|
||
|
|
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||
|
|
className="fixed right-0 top-0 bottom-0 w-full max-w-2xl bg-white shadow-2xl z-50 overflow-hidden flex flex-col"
|
||
|
|
>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center justify-between p-6 border-b border-gray-200 bg-gray-50">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-2xl font-bold text-deep-sea-blue">Order Details</h2>
|
||
|
|
<p className="text-sm text-deep-sea-blue/60 mt-1">
|
||
|
|
ID: {order.orderId.substring(0, 12)}...
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||
|
|
>
|
||
|
|
<X className="w-6 h-6 text-deep-sea-blue" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Scrollable Content */}
|
||
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
||
|
|
{/* Order Information */}
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Order Information" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<DetailField label="Order ID" value={order.orderId} copyable fieldName="orderId" />
|
||
|
|
<div className="py-3 border-b border-gray-100">
|
||
|
|
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">Status</div>
|
||
|
|
<span
|
||
|
|
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeClass(
|
||
|
|
order.status
|
||
|
|
)}`}
|
||
|
|
>
|
||
|
|
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<DetailField label="Source" value={order.source || 'web'} />
|
||
|
|
<DetailField label="Created At" value={formatDate(order.CreatedAt)} />
|
||
|
|
<DetailField label="Last Updated" value={formatDate(order.UpdatedAt)} />
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Payment Details */}
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Payment Details" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<DetailField
|
||
|
|
label="Total Amount"
|
||
|
|
value={formatCurrency(order.totalAmount, order.currency)}
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Base Amount"
|
||
|
|
value={formatCurrency(order.baseAmount, order.currency)}
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Processing Fee"
|
||
|
|
value={formatCurrency(order.processingFee, order.currency)}
|
||
|
|
/>
|
||
|
|
<DetailField label="Currency" value={order.currency} />
|
||
|
|
{order.amountUSD && (
|
||
|
|
<DetailField label="Amount (USD)" value={formatCurrency(order.amountUSD, 'USD')} />
|
||
|
|
)}
|
||
|
|
<DetailField label="Payment Method" value={order.paymentMethod} />
|
||
|
|
<DetailField
|
||
|
|
label="Stripe Session ID"
|
||
|
|
value={order.stripeSessionId}
|
||
|
|
copyable
|
||
|
|
fieldName="stripeSessionId"
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Stripe Payment Intent"
|
||
|
|
value={order.stripePaymentIntent}
|
||
|
|
copyable
|
||
|
|
fieldName="stripePaymentIntent"
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Stripe Customer ID"
|
||
|
|
value={order.stripeCustomerId}
|
||
|
|
copyable
|
||
|
|
fieldName="stripeCustomerId"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Customer Information */}
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Customer Information" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<DetailField label="Name" value={order.customerName} />
|
||
|
|
<DetailField label="Email" value={order.customerEmail} copyable fieldName="email" />
|
||
|
|
<DetailField label="Phone" value={order.customerPhone} />
|
||
|
|
{order.businessName && (
|
||
|
|
<>
|
||
|
|
<DetailField label="Business Name" value={order.businessName} />
|
||
|
|
<DetailField label="Tax ID Type" value={order.taxIdType} />
|
||
|
|
<DetailField
|
||
|
|
label="Tax ID Value"
|
||
|
|
value={order.taxIdValue}
|
||
|
|
copyable
|
||
|
|
fieldName="taxId"
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Billing Address */}
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Billing Address" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<DetailField label="Address Line 1" value={order.billingLine1} />
|
||
|
|
<DetailField label="Address Line 2" value={order.billingLine2} />
|
||
|
|
<DetailField label="City" value={order.billingCity} />
|
||
|
|
<DetailField label="State/Province" value={order.billingState} />
|
||
|
|
<DetailField label="Postal Code" value={order.billingPostalCode} />
|
||
|
|
<DetailField label="Country" value={order.billingCountry} />
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Carbon Offset Details */}
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Carbon Offset Details" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<div className="py-3 border-b border-gray-100">
|
||
|
|
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">CO₂ Offset</div>
|
||
|
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-sea-green/20 text-sea-green border border-sea-green/30">
|
||
|
|
{parseFloat(order.co2Tons).toFixed(2)} tons
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<DetailField label="Portfolio ID" value={order.portfolioId} />
|
||
|
|
<DetailField label="Portfolio Name" value={order.portfolioName} />
|
||
|
|
<DetailField
|
||
|
|
label="Wren Order ID"
|
||
|
|
value={order.wrenOrderId}
|
||
|
|
copyable
|
||
|
|
fieldName="wrenOrderId"
|
||
|
|
/>
|
||
|
|
{order.certificateUrl && (
|
||
|
|
<div className="py-3">
|
||
|
|
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">
|
||
|
|
Certificate URL
|
||
|
|
</div>
|
||
|
|
<a
|
||
|
|
href={order.certificateUrl}
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="text-sm text-maritime-teal hover:text-maritime-teal/80 flex items-center font-medium"
|
||
|
|
>
|
||
|
|
View Certificate
|
||
|
|
<ExternalLink className="w-4 h-4 ml-1" />
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<DetailField label="Fulfilled At" value={formatDate(order.fulfilledAt)} />
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
{/* Vessel Information (if applicable) */}
|
||
|
|
{(order.vesselName || order.imoNumber || order.vesselType || order.vesselLength) && (
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Vessel Information" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<DetailField label="Vessel Name" value={order.vesselName} />
|
||
|
|
<DetailField label="IMO Number" value={order.imoNumber} />
|
||
|
|
<DetailField label="Vessel Type" value={order.vesselType} />
|
||
|
|
<DetailField
|
||
|
|
label="Vessel Length"
|
||
|
|
value={order.vesselLength ? `${order.vesselLength}m` : undefined}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Trip Details (if applicable) */}
|
||
|
|
{(order.departurePort ||
|
||
|
|
order.arrivalPort ||
|
||
|
|
order.distance ||
|
||
|
|
order.avgSpeed ||
|
||
|
|
order.duration ||
|
||
|
|
order.enginePower) && (
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Trip Details" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<DetailField label="Departure Port" value={order.departurePort} />
|
||
|
|
<DetailField label="Arrival Port" value={order.arrivalPort} />
|
||
|
|
<DetailField
|
||
|
|
label="Distance"
|
||
|
|
value={order.distance ? `${order.distance} nm` : undefined}
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Average Speed"
|
||
|
|
value={order.avgSpeed ? `${order.avgSpeed} knots` : undefined}
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Duration"
|
||
|
|
value={order.duration ? `${order.duration} hours` : undefined}
|
||
|
|
/>
|
||
|
|
<DetailField
|
||
|
|
label="Engine Power"
|
||
|
|
value={order.enginePower ? `${order.enginePower} HP` : undefined}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Admin Notes */}
|
||
|
|
{order.notes && (
|
||
|
|
<section>
|
||
|
|
<SectionHeader title="Admin Notes" />
|
||
|
|
<div className="bg-gray-50 rounded-xl p-4">
|
||
|
|
<p className="text-sm text-deep-sea-blue whitespace-pre-wrap">{order.notes}</p>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Footer */}
|
||
|
|
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
className="px-6 py-2.5 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 transition-colors"
|
||
|
|
>
|
||
|
|
Close
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</motion.div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</AnimatePresence>
|
||
|
|
);
|
||
|
|
}
|