feat(checkout): searchable address fields and Rajaongkir integration

Admin SPA:
- Changed billing/shipping state from Select to SearchableSelect

Customer SPA:
- Added cmdk package for command palette
- Created popover, command, and searchable-select UI components
- Added searchable country and state fields to checkout
- Fetches countries/states from /countries API
- Auto-clears state when country changes

Backend:
- Added generic woonoow/shipping/before_calculate hook
- Removed hardcoded Rajaongkir session handling

Documentation:
- Updated RAJAONGKIR_INTEGRATION.md with:
  - Complete searchable destination selector plugin code
  - JavaScript implementation
  - React component version
  - REST API endpoint for destination search
This commit is contained in:
Dwindi Ramadhana
2026-01-08 11:19:37 +07:00
parent 786e01c8f6
commit 2939ebfe6b
7 changed files with 730 additions and 127 deletions

View File

@@ -37,133 +37,433 @@ do_action( 'woonoow/shipping/before_calculate', $shipping_data, $items );
- `city` - City name - `city` - City name
- `postcode` - Postal code - `postcode` - Postal code
- `address_1` - Street address - `address_1` - Street address
- `address_2` - Additional address (optional) - `destination_id` - Custom field for Rajaongkir (added via addon)
- + Any custom fields added by addons - + Any custom fields added by addons
- `$items` (array) - Cart items being shipped - `$items` (array) - Cart items being shipped
--- ---
## Code Snippet: Rajaongkir Bridge ## Complete Integration: Searchable Destination Selector
Add this code to your theme's `functions.php` or via a code snippets plugin: This is a complete code solution that adds a searchable Rajaongkir destination selector to WooNooW checkout.
### Plugin File: `woonoow-rajaongkir-bridge.php`
Create this as a new plugin or add to your theme's functions.php:
```php ```php
<?php <?php
/** /**
* Bridge Rajaongkir plugin with WooNooW shipping calculation. * Plugin Name: WooNooW Rajaongkir Bridge
* * Description: Adds searchable destination selector for Rajaongkir shipping
* This code listens for WooNooW's shipping hook and sets the * Version: 1.0.0
* Rajaongkir session variables it needs to calculate rates. * Requires Plugins: woonoow, cekongkir
*/
if ( ! defined( 'ABSPATH' ) ) exit;
/**
* 1. Hook into WooNooW shipping calculation to set Rajaongkir session
*/ */
add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) { add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
// Only process for Indonesia // Only process for Indonesia
if ( empty( $shipping['country'] ) || $shipping['country'] !== 'ID' ) { if ( empty( $shipping['country'] ) || $shipping['country'] !== 'ID' ) {
// Clear Rajaongkir session for non-ID countries
WC()->session->__unset( 'selected_destination_id' ); WC()->session->__unset( 'selected_destination_id' );
WC()->session->__unset( 'selected_destination_label' ); WC()->session->__unset( 'selected_destination_label' );
return; return;
} }
// Check if destination_id is provided by frontend // Set destination from frontend
if ( ! empty( $shipping['destination_id'] ) ) { if ( ! empty( $shipping['destination_id'] ) ) {
WC()->session->set( 'selected_destination_id', $shipping['destination_id'] ); WC()->session->set( 'selected_destination_id', intval( $shipping['destination_id'] ) );
WC()->session->set( 'selected_destination_label', $shipping['destination_label'] ?? $shipping['city'] ); WC()->session->set( 'selected_destination_label', sanitize_text_field( $shipping['destination_label'] ?? '' ) );
return;
}
// Fallback: Try to lookup destination from city name
// This requires the Rajaongkir database lookup
$destination = rajaongkir_lookup_destination( $shipping['city'], $shipping['state'] );
if ( $destination ) {
WC()->session->set( 'selected_destination_id', $destination['id'] );
WC()->session->set( 'selected_destination_label', $destination['label'] );
} }
}, 10, 2 ); }, 10, 2 );
/** /**
* Helper: Lookup Rajaongkir destination by city/state name. * 2. REST API: Search Rajaongkir destinations
*
* This is a simplified example. Implement based on your Rajaongkir data structure.
*/ */
function rajaongkir_lookup_destination( $city, $state ) { add_action( 'rest_api_init', function() {
// Query the Rajaongkir location database register_rest_route( 'woonoow/v1', '/rajaongkir/destinations', [
// This depends on how your Rajaongkir plugin stores location data 'methods' => 'GET',
'callback' => 'woonoow_search_rajaongkir_destinations',
// Example using transient/option storage: 'permission_callback' => '__return_true',
$locations = get_option( 'cekongkir_destinations', [] ); 'args' => [
'search' => [
foreach ( $locations as $location ) {
if (
stripos( $location['city'], $city ) !== false ||
stripos( $location['district'], $city ) !== false
) {
return [
'id' => $location['id'],
'label' => $location['label'],
];
}
}
return null;
}
```
---
## Frontend Integration (Optional)
To add a proper Rajaongkir destination selector to WooNooW's admin order form, you need to:
### 1. Register Custom Address Fields
```php
add_filter( 'woonoow/checkout/address_fields', function( $fields ) {
// Only for Indonesia
if ( WC()->customer && WC()->customer->get_shipping_country() === 'ID' ) {
$fields['destination_id'] = [
'type' => 'hidden',
'required' => true, 'required' => true,
]; 'type' => 'string',
$fields['destination_selector'] = [ 'sanitize_callback' => 'sanitize_text_field',
'type' => 'custom', ],
'component' => 'RajaongkirDestinationSelector', ],
'label' => __( 'Destination', 'woonoow-rajaongkir' ), ]);
'required' => true,
];
}
return $fields;
}); });
```
### 2. Enqueue JavaScript for Destination Search function woonoow_search_rajaongkir_destinations( $request ) {
$search = $request->get_param( 'search' );
if ( strlen( $search ) < 2 ) {
return new WP_REST_Response( [], 200 );
}
// Get Rajaongkir destinations from transient/cache
$destinations = get_transient( 'cekongkir_all_destinations' );
if ( ! $destinations ) {
// Fetch from Rajaongkir API or database
$destinations = woonoow_fetch_rajaongkir_destinations();
set_transient( 'cekongkir_all_destinations', $destinations, DAY_IN_SECONDS );
}
// Filter by search term
$results = array_filter( $destinations, function( $dest ) use ( $search ) {
return stripos( $dest['label'], $search ) !== false;
});
// Limit to 50 results
$results = array_slice( array_values( $results ), 0, 50 );
return new WP_REST_Response( $results, 200 );
}
/**
* 3. Fetch destinations from Rajaongkir database
*/
function woonoow_fetch_rajaongkir_destinations() {
global $wpdb;
$destinations = [];
// Try to get from Rajaongkir plugin's stored data
$table = $wpdb->prefix . 'cekongkir_destinations';
if ( $wpdb->get_var( "SHOW TABLES LIKE '$table'" ) === $table ) {
$rows = $wpdb->get_results( "SELECT * FROM $table", ARRAY_A );
foreach ( $rows as $row ) {
$destinations[] = [
'value' => $row['id'],
'label' => $row['province'] . ', ' . $row['city'] . ', ' . $row['subdistrict'],
'province' => $row['province'],
'city' => $row['city'],
'subdistrict' => $row['subdistrict'],
];
}
}
// Fallback: Use options storage
if ( empty( $destinations ) ) {
$stored = get_option( 'cekongkir_destinations', [] );
foreach ( $stored as $id => $label ) {
$destinations[] = [
'value' => $id,
'label' => $label,
];
}
}
return $destinations;
}
/**
* 4. Enqueue scripts for checkout
*/
add_action( 'wp_enqueue_scripts', function() {
if ( ! is_checkout() && ! is_wc_endpoint_url() ) {
return;
}
```php
add_action( 'woonoow/admin/enqueue_scripts', function() {
wp_enqueue_script( wp_enqueue_script(
'woonoow-rajaongkir', 'woonoow-rajaongkir-bridge',
plugins_url( 'assets/js/woonoow-integration.js', __FILE__ ), plugin_dir_url( __FILE__ ) . 'rajaongkir-bridge.js',
[ 'woonoow-admin' ], [],
'1.0.0', '1.0.0',
true true
); );
wp_localize_script( 'woonoow-rajaongkir', 'WNW_RAJAONGKIR', [ wp_localize_script( 'woonoow-rajaongkir-bridge', 'WNW_RAJAONGKIR', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'apiUrl' => rest_url( 'woonoow/v1/rajaongkir/destinations' ),
'nonce' => wp_create_nonce( 'rajaongkir_search' ), 'nonce' => wp_create_nonce( 'wp_rest' ),
]); ]);
}); });
``` ```
### JavaScript: `rajaongkir-bridge.js`
This script adds a searchable destination selector to the checkout:
```javascript
/**
* Rajaongkir Destination Selector for WooNooW
*
* Adds a searchable dropdown to select Indonesian subdistrict
*/
(function() {
'use strict';
// Only run on checkout
if (!document.querySelector('.woonoow-checkout, .woocommerce-checkout')) {
return;
}
// Wait for DOM
document.addEventListener('DOMContentLoaded', function() {
initRajaongkirSelector();
});
function initRajaongkirSelector() {
// Find the city/state fields in shipping form
const countryField = document.querySelector('[name="shipping_country"], [name="billing_country"]');
if (!countryField) return;
// Check if Indonesia is selected
const checkCountry = () => {
const country = countryField.value;
if (country === 'ID') {
showDestinationSelector();
} else {
hideDestinationSelector();
}
};
countryField.addEventListener('change', checkCountry);
checkCountry();
}
function showDestinationSelector() {
// Create searchable destination field if not exists
if (document.querySelector('#rajaongkir-destination-wrapper')) {
document.querySelector('#rajaongkir-destination-wrapper').style.display = 'block';
return;
}
const wrapper = document.createElement('div');
wrapper.id = 'rajaongkir-destination-wrapper';
wrapper.className = 'form-group md:col-span-2';
wrapper.innerHTML = `
<label class="block text-sm font-medium mb-2">
Destination (Province, City, Subdistrict) *
</label>
<div class="relative">
<input
type="text"
id="rajaongkir-search"
placeholder="Search destination..."
class="w-full border rounded-lg px-4 py-2"
autocomplete="off"
/>
<input type="hidden" id="rajaongkir-destination-id" name="destination_id" />
<input type="hidden" id="rajaongkir-destination-label" name="destination_label" />
<div id="rajaongkir-results" class="absolute z-50 w-full mt-1 bg-white border rounded-lg shadow-lg max-h-64 overflow-y-auto hidden"></div>
</div>
`;
// Insert after postcode field
const postcodeField = document.querySelector('[name="shipping_postcode"], [name="billing_postcode"]');
if (postcodeField) {
postcodeField.closest('.form-group, div')?.after(wrapper);
}
setupSearchHandler();
}
function hideDestinationSelector() {
const wrapper = document.querySelector('#rajaongkir-destination-wrapper');
if (wrapper) {
wrapper.style.display = 'none';
}
}
let searchTimeout;
function setupSearchHandler() {
const searchInput = document.querySelector('#rajaongkir-search');
const resultsDiv = document.querySelector('#rajaongkir-results');
const destinationIdInput = document.querySelector('#rajaongkir-destination-id');
const destinationLabelInput = document.querySelector('#rajaongkir-destination-label');
searchInput.addEventListener('input', function(e) {
const query = e.target.value;
clearTimeout(searchTimeout);
if (query.length < 2) {
resultsDiv.classList.add('hidden');
return;
}
searchTimeout = setTimeout(() => {
searchDestinations(query);
}, 300);
});
async function searchDestinations(query) {
try {
const response = await fetch(
`${WNW_RAJAONGKIR.apiUrl}?search=${encodeURIComponent(query)}`,
{
headers: {
'X-WP-Nonce': WNW_RAJAONGKIR.nonce,
},
}
);
const destinations = await response.json();
if (destinations.length === 0) {
resultsDiv.innerHTML = '<div class="p-3 text-gray-500">No destinations found</div>';
} else {
resultsDiv.innerHTML = destinations.map(dest => `
<div
class="p-3 hover:bg-gray-100 cursor-pointer border-b last:border-0"
data-value="${dest.value}"
data-label="${dest.label}"
>
${dest.label}
</div>
`).join('');
}
resultsDiv.classList.remove('hidden');
// Setup click handlers
resultsDiv.querySelectorAll('[data-value]').forEach(item => {
item.addEventListener('click', function() {
const value = this.dataset.value;
const label = this.dataset.label;
searchInput.value = label;
destinationIdInput.value = value;
destinationLabelInput.value = label;
resultsDiv.classList.add('hidden');
// Trigger shipping calculation
document.body.dispatchEvent(new Event('woonoow:address_changed'));
});
});
} catch (error) {
console.error('Rajaongkir search error:', error);
}
}
// Hide on click outside
document.addEventListener('click', function(e) {
if (!e.target.closest('#rajaongkir-destination-wrapper')) {
resultsDiv.classList.add('hidden');
}
});
}
})();
```
---
## React Component Version (for customer-spa)
If you're extending the customer-spa directly, here's a React component:
```tsx
// components/RajaongkirDestinationSelector.tsx
import React, { useState, useEffect } from 'react';
import { api } from '@/lib/api/client';
import { SearchableSelect } from '@/components/ui/searchable-select';
interface DestinationOption {
value: string;
label: string;
}
interface Props {
value?: string;
label?: string;
onChange: (id: string, label: string) => void;
}
export function RajaongkirDestinationSelector({ value, label, onChange }: Props) {
const [search, setSearch] = useState('');
const [options, setOptions] = useState<DestinationOption[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (search.length < 2) {
setOptions([]);
return;
}
const timer = setTimeout(async () => {
setLoading(true);
try {
const data = await api.get<DestinationOption[]>('/rajaongkir/destinations', { search });
setOptions(data);
} catch (error) {
console.error('Failed to search destinations:', error);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(timer);
}, [search]);
const handleSelect = (selectedValue: string) => {
const selected = options.find(o => o.value === selectedValue);
if (selected) {
onChange(selected.value, selected.label);
}
};
return (
<div>
<label className="block text-sm font-medium mb-2">
Destination (Province, City, Subdistrict) *
</label>
<SearchableSelect
options={options}
value={value || ''}
onChange={handleSelect}
placeholder={loading ? 'Searching...' : 'Search destination...'}
emptyLabel="Type to search destinations"
/>
{label && (
<p className="text-xs text-gray-500 mt-1">Selected: {label}</p>
)}
</div>
);
}
```
Usage in Checkout:
```tsx
// In Checkout/index.tsx
import { RajaongkirDestinationSelector } from '@/components/RajaongkirDestinationSelector';
// In the shipping form, add:
{shippingData.country === 'ID' && (
<div className="md:col-span-2">
<RajaongkirDestinationSelector
value={shippingData.destination_id}
label={shippingData.destination_label}
onChange={(id, label) => setShippingData({
...shippingData,
destination_id: id,
destination_label: label,
})}
/>
</div>
)}
```
--- ---
## Testing ## Testing
1. Install and configure Rajaongkir plugin 1. Install Rajaongkir plugin and configure API key
2. Add the code snippet above 2. Add the bridge code above
3. Go to WooNooW → Orders → Create New Order 3. Go to WooNooW Checkout
4. Add a product 4. Set country to Indonesia
5. Set country to Indonesia 5. Type in the destination search field
6. Fill in city/state (or use destination selector if implemented) 6. Select a destination from dropdown
7. Click "Calculate Shipping" 7. Click "Calculate Shipping"
8. Verify Rajaongkir rates appear 8. Verify Rajaongkir rates appear
@@ -171,29 +471,21 @@ add_action( 'woonoow/admin/enqueue_scripts', function() {
## Troubleshooting ## Troubleshooting
### Rates not appearing? ### Destinations not loading?
1. Check browser DevTools Network tab for `/shipping/calculate` response Check the REST API endpoint:
2. Look for `debug` object in response:
```json
{
"methods": [...],
"debug": {
"packages_count": 1,
"cart_items_count": 1,
"address": { ... }
}
}
``` ```
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=jakarta
3. Verify Rajaongkir session is set:
```php
error_log( 'destination_id: ' . WC()->session->get( 'selected_destination_id' ) );
``` ```
### Session not persisting? ### Session not persisting?
Make sure WooCommerce session is initialized before the hook runs. The hook fires during REST API calls where session may not be initialized by default. Verify the hook is firing:
```php
add_action( 'woonoow/shipping/before_calculate', function( $shipping, $items ) {
error_log( 'Shipping data: ' . print_r( $shipping, true ) );
}, 5, 2 );
```
--- ---

View File

@@ -25,6 +25,7 @@
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.547.0", "lucide-react": "^0.547.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -3597,6 +3598,22 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",

View File

@@ -27,6 +27,7 @@
"@tanstack/react-query": "^5.90.5", "@tanstack/react-query": "^5.90.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"lucide-react": "^0.547.0", "lucide-react": "^0.547.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -0,0 +1,107 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-white text-gray-900",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-gray-400 disabled:cursor-not-allowed disabled:opacity-50 border-none",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-gray-900",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-gray-100 data-[selected=true]:text-gray-900 data-[disabled=true]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
}

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-white p-4 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,99 @@
import * as React from "react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import {
Command,
CommandInput,
CommandList,
CommandItem,
CommandEmpty,
} from "@/components/ui/command";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Option {
value: string;
label: string;
searchText?: string;
}
interface Props {
value?: string;
onChange?: (v: string) => void;
options: Option[];
placeholder?: string;
emptyLabel?: string;
className?: string;
disabled?: boolean;
}
export function SearchableSelect({
value,
onChange,
options,
placeholder = "Select...",
emptyLabel = "No results found.",
className,
disabled = false,
}: Props) {
const [open, setOpen] = React.useState(false);
const selected = options.find((o) => o.value === value);
React.useEffect(() => { if (disabled && open) setOpen(false); }, [disabled, open]);
return (
<Popover open={disabled ? false : open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
className={cn(
"w-full flex items-center justify-between border rounded-lg px-4 py-2 text-left bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary/20",
disabled && "opacity-50 cursor-not-allowed",
className
)}
disabled={disabled}
aria-disabled={disabled}
>
<span className={selected ? "text-gray-900" : "text-gray-400"}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown className="opacity-50 h-4 w-4 shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[--radix-popover-trigger-width]"
align="start"
sideOffset={4}
>
<Command shouldFilter>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>{emptyLabel}</CommandEmpty>
{options.map((opt) => (
<CommandItem
key={opt.value}
value={opt.searchText || opt.label || opt.value}
onSelect={() => {
onChange?.(opt.value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
opt.value === value ? "opacity-100" : "opacity-0"
)}
/>
{opt.label}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useCartStore } from '@/lib/cart/store'; import { useCartStore } from '@/lib/cart/store';
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings'; import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { SearchableSelect } from '@/components/ui/searchable-select';
import Container from '@/components/Layout/Container'; import Container from '@/components/Layout/Container';
import SEOHead from '@/components/SEOHead'; import SEOHead from '@/components/SEOHead';
import { formatPrice } from '@/lib/currency'; import { formatPrice } from '@/lib/currency';
@@ -97,6 +98,62 @@ export default function Checkout() {
const [showBillingForm, setShowBillingForm] = useState(true); const [showBillingForm, setShowBillingForm] = useState(true);
const [showShippingForm, setShowShippingForm] = useState(true); const [showShippingForm, setShowShippingForm] = useState(true);
// Countries and states data
const [countries, setCountries] = useState<{ code: string; name: string }[]>([]);
const [states, setStates] = useState<Record<string, Record<string, string>>>({});
const [defaultCountry, setDefaultCountry] = useState('');
// Load countries and states
useEffect(() => {
const loadCountries = async () => {
try {
const data = await api.get<{
countries: { code: string; name: string }[];
states: Record<string, Record<string, string>>;
default_country: string;
}>('/countries');
setCountries(data.countries || []);
setStates(data.states || {});
setDefaultCountry(data.default_country || '');
// Set default country if not already set
if (!billingData.country && data.default_country) {
setBillingData(prev => ({ ...prev, country: data.default_country }));
}
if (!shippingData.country && data.default_country) {
setShippingData(prev => ({ ...prev, country: data.default_country }));
}
} catch (error) {
console.error('Failed to load countries:', error);
}
};
loadCountries();
}, []);
// Country/state options for SearchableSelect
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
const billingStateOptions = Object.entries(states[billingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
const shippingStateOptions = Object.entries(states[shippingData.country] || {}).map(([code, name]) => ({ value: code, label: name }));
// Clear state when country changes
useEffect(() => {
if (billingData.country && billingData.state) {
const countryStates = states[billingData.country] || {};
if (!countryStates[billingData.state]) {
setBillingData(prev => ({ ...prev, state: '' }));
}
}
}, [billingData.country, states]);
useEffect(() => {
if (shippingData.country && shippingData.state) {
const countryStates = states[shippingData.country] || {};
if (!countryStates[shippingData.state]) {
setShippingData(prev => ({ ...prev, state: '' }));
}
}
}, [shippingData.country, states]);
// Load saved addresses // Load saved addresses
useEffect(() => { useEffect(() => {
const loadAddresses = async () => { const loadAddresses = async () => {
@@ -491,14 +548,24 @@ export default function Checkout() {
className="w-full border rounded-lg px-4 py-2" className="w-full border rounded-lg px-4 py-2"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium mb-2">Country *</label>
<SearchableSelect
options={countryOptions}
value={billingData.country}
onChange={(v) => setBillingData({ ...billingData, country: v })}
placeholder="Select country"
disabled={countries.length <= 1}
/>
</div>
<div> <div>
<label className="block text-sm font-medium mb-2">State / Province *</label> <label className="block text-sm font-medium mb-2">State / Province *</label>
<input <SearchableSelect
type="text" options={billingStateOptions}
required
value={billingData.state} value={billingData.state}
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })} onChange={(v) => setBillingData({ ...billingData, state: v })}
className="w-full border rounded-lg px-4 py-2" placeholder={billingStateOptions.length ? "Select state" : "N/A"}
disabled={!billingStateOptions.length}
/> />
</div> </div>
<div> <div>
@@ -511,16 +578,6 @@ export default function Checkout() {
className="w-full border rounded-lg px-4 py-2" className="w-full border rounded-lg px-4 py-2"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium mb-2">Country *</label>
<input
type="text"
required
value={billingData.country}
onChange={(e) => setBillingData({ ...billingData, country: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
</> </>
)} )}
</div> </div>
@@ -652,14 +709,24 @@ export default function Checkout() {
className="w-full border rounded-lg px-4 py-2" className="w-full border rounded-lg px-4 py-2"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium mb-2">Country *</label>
<SearchableSelect
options={countryOptions}
value={shippingData.country}
onChange={(v) => setShippingData({ ...shippingData, country: v })}
placeholder="Select country"
disabled={countries.length <= 1}
/>
</div>
<div> <div>
<label className="block text-sm font-medium mb-2">State / Province *</label> <label className="block text-sm font-medium mb-2">State / Province *</label>
<input <SearchableSelect
type="text" options={shippingStateOptions}
required
value={shippingData.state} value={shippingData.state}
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })} onChange={(v) => setShippingData({ ...shippingData, state: v })}
className="w-full border rounded-lg px-4 py-2" placeholder={shippingStateOptions.length ? "Select state" : "N/A"}
disabled={!shippingStateOptions.length}
/> />
</div> </div>
<div> <div>
@@ -672,16 +739,6 @@ export default function Checkout() {
className="w-full border rounded-lg px-4 py-2" className="w-full border rounded-lg px-4 py-2"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium mb-2">Country *</label>
<input
type="text"
required
value={shippingData.country}
onChange={(e) => setShippingData({ ...shippingData, country: e.target.value })}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
</div> </div>
)} )}
</> </>