feat: implement multiple saved addresses with modal selector in checkout

- Add AddressController with full CRUD API for saved addresses
- Implement address management UI in My Account > Addresses
- Add modal-based address selector in checkout (Tokopedia-style)
- Hide checkout forms when saved address is selected
- Add search functionality in address modal
- Auto-select default addresses on page load
- Fix variable products to show 'Select Options' instead of 'Add to Cart'
- Add admin toggle for multiple addresses feature
- Clean up debug logs and fix TypeScript errors
This commit is contained in:
Dwindi Ramadhana
2025-12-26 01:16:11 +07:00
parent 9ac09582d2
commit 100f9cce55
27 changed files with 2492 additions and 205 deletions

View File

@@ -5,9 +5,29 @@ import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
import { Button } from '@/components/ui/button';
import Container from '@/components/Layout/Container';
import { formatPrice } from '@/lib/currency';
import { ArrowLeft, ShoppingBag } from 'lucide-react';
import { ArrowLeft, ShoppingBag, MapPin, Check, Edit2 } from 'lucide-react';
import { toast } from 'sonner';
import { apiClient } from '@/lib/api/client';
import { api } from '@/lib/api/client';
import { AddressSelector } from '@/components/AddressSelector';
interface SavedAddress {
id: number;
label: string;
type: 'billing' | 'shipping' | 'both';
first_name: string;
last_name: string;
company?: string;
address_1: string;
address_2?: string;
city: string;
state: string;
postcode: string;
country: string;
email?: string;
phone?: string;
is_default: boolean;
}
export default function Checkout() {
const navigate = useNavigate();
@@ -54,10 +74,95 @@ export default function Checkout() {
const [shipToDifferentAddress, setShipToDifferentAddress] = useState(false);
const [orderNotes, setOrderNotes] = useState('');
const [paymentMethod, setPaymentMethod] = useState(isVirtualOnly ? 'bacs' : 'cod');
// Saved addresses
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
const [selectedBillingAddressId, setSelectedBillingAddressId] = useState<number | null>(null);
const [selectedShippingAddressId, setSelectedShippingAddressId] = useState<number | null>(null);
const [loadingAddresses, setLoadingAddresses] = useState(true);
const [showBillingModal, setShowBillingModal] = useState(false);
const [showShippingModal, setShowShippingModal] = useState(false);
const [showBillingForm, setShowBillingForm] = useState(true);
const [showShippingForm, setShowShippingForm] = useState(true);
// Load saved addresses
useEffect(() => {
const loadAddresses = async () => {
if (!user?.isLoggedIn) {
setLoadingAddresses(false);
return;
}
try {
const addresses = await api.get<SavedAddress[]>('/account/addresses');
setSavedAddresses(addresses);
// Auto-select default addresses
const defaultBilling = addresses.find(a => a.is_default && (a.type === 'billing' || a.type === 'both'));
const defaultShipping = addresses.find(a => a.is_default && (a.type === 'shipping' || a.type === 'both'));
if (defaultBilling) {
setSelectedBillingAddressId(defaultBilling.id);
fillBillingFromAddress(defaultBilling);
setShowBillingForm(false); // Hide form when default address is auto-selected
}
if (defaultShipping && !isVirtualOnly) {
setSelectedShippingAddressId(defaultShipping.id);
fillShippingFromAddress(defaultShipping);
setShowShippingForm(false); // Hide form when default address is auto-selected
}
} catch (error) {
console.error('Failed to load addresses:', error);
} finally {
setLoadingAddresses(false);
}
};
loadAddresses();
}, [user, isVirtualOnly]);
// Helper functions to fill forms from saved addresses
const fillBillingFromAddress = (address: SavedAddress) => {
setBillingData({
firstName: address.first_name,
lastName: address.last_name,
email: address.email || billingData.email,
phone: address.phone || billingData.phone,
address: address.address_1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
});
};
const fillShippingFromAddress = (address: SavedAddress) => {
setShippingData({
firstName: address.first_name,
lastName: address.last_name,
address: address.address_1,
city: address.city,
state: address.state,
postcode: address.postcode,
country: address.country,
});
};
const handleSelectBillingAddress = (address: SavedAddress) => {
setSelectedBillingAddressId(address.id);
fillBillingFromAddress(address);
setShowBillingForm(false); // Hide form when address is selected
};
const handleSelectShippingAddress = (address: SavedAddress) => {
setSelectedShippingAddressId(address.id);
fillShippingFromAddress(address);
setShowShippingForm(false); // Hide form when address is selected
};
// Auto-fill form with user data if logged in
useEffect(() => {
if (user?.isLoggedIn && user?.billing) {
if (user?.isLoggedIn && user?.billing && savedAddresses.length === 0) {
setBillingData({
firstName: user.billing.first_name || '',
lastName: user.billing.last_name || '',
@@ -183,7 +288,75 @@ export default function Checkout() {
<div className={`grid gap-8 ${layout.style === 'single-column' ? 'grid-cols-1' : layout.order_summary === 'top' ? 'grid-cols-1' : 'lg:grid-cols-3'}`}>
{/* Billing & Shipping Forms */}
<div className={`space-y-6 ${layout.style === 'single-column' || layout.order_summary === 'top' ? '' : 'lg:col-span-2'}`}>
{/* Billing Details */}
{/* Selected Billing Address Summary */}
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'billing' || a.type === 'both') && (
<div className="bg-white border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold flex items-center gap-2">
<MapPin className="w-5 h-5" />
Billing Address
</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowBillingModal(true)}
className="flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Change Address
</Button>
</div>
{selectedBillingAddressId ? (
(() => {
const selected = savedAddresses.find(a => a.id === selectedBillingAddressId);
return selected ? (
<div>
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold">{selected.label}</p>
{selected.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
)}
</div>
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
<p className="text-sm text-gray-600">{selected.country}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowBillingForm(true)}
className="mt-3 text-primary hover:text-primary"
>
Use a different address
</Button>
</div>
) : null;
})()
) : (
<p className="text-gray-500 text-sm">No address selected</p>
)}
</div>
)}
{/* Billing Address Modal */}
<AddressSelector
isOpen={showBillingModal}
onClose={() => setShowBillingModal(false)}
addresses={savedAddresses}
selectedAddressId={selectedBillingAddressId}
onSelectAddress={handleSelectBillingAddress}
type="billing"
/>
{/* Billing Details Form - Only show if no saved address selected or user wants to enter manually */}
{(savedAddresses.length === 0 || !selectedBillingAddressId || showBillingForm) && (
<div className="bg-white border rounded-lg p-6">
<h2 className="text-xl font-bold mb-4">Billing Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -285,6 +458,7 @@ export default function Checkout() {
)}
</div>
</div>
)}
{/* Ship to Different Address - only for physical products */}
{!isVirtualOnly && (
@@ -300,7 +474,77 @@ export default function Checkout() {
</label>
{shipToDifferentAddress && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<>
{/* Selected Shipping Address Summary */}
{!loadingAddresses && savedAddresses.length > 0 && savedAddresses.some(a => a.type === 'shipping' || a.type === 'both') && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold flex items-center gap-2">
<MapPin className="w-4 h-4" />
Shipping Address
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowShippingModal(true)}
className="flex items-center gap-2"
>
<Edit2 className="w-4 h-4" />
Change Address
</Button>
</div>
{selectedShippingAddressId ? (
(() => {
const selected = savedAddresses.find(a => a.id === selectedShippingAddressId);
return selected ? (
<div>
<div className="bg-primary/5 border-2 border-primary rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<p className="font-semibold">{selected.label}</p>
{selected.is_default && (
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">Default</span>
)}
</div>
<p className="text-sm font-medium text-gray-900">{selected.first_name} {selected.last_name}</p>
{selected.phone && <p className="text-sm text-gray-600">{selected.phone}</p>}
<p className="text-sm text-gray-600 mt-2">{selected.address_1}</p>
{selected.address_2 && <p className="text-sm text-gray-600">{selected.address_2}</p>}
<p className="text-sm text-gray-600">{selected.city}, {selected.state} {selected.postcode}</p>
<p className="text-sm text-gray-600">{selected.country}</p>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowShippingForm(true)}
className="mt-3 text-primary hover:text-primary"
>
Use a different address
</Button>
</div>
) : null;
})()
) : (
<p className="text-gray-500 text-sm">No address selected</p>
)}
</div>
)}
{/* Shipping Address Modal */}
<AddressSelector
isOpen={showShippingModal}
onClose={() => setShowShippingModal(false)}
addresses={savedAddresses}
selectedAddressId={selectedShippingAddressId}
onSelectAddress={handleSelectShippingAddress}
type="shipping"
/>
{/* Shipping Form - Only show if no saved address selected or user wants to enter manually */}
{(!selectedShippingAddressId || showShippingForm) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">First Name *</label>
<input
@@ -372,6 +616,8 @@ export default function Checkout() {
/>
</div>
</div>
)}
</>
)}
</div>
)}