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:
@@ -37,133 +37,433 @@ do_action( 'woonoow/shipping/before_calculate', $shipping_data, $items );
|
||||
- `city` - City name
|
||||
- `postcode` - Postal code
|
||||
- `address_1` - Street address
|
||||
- `address_2` - Additional address (optional)
|
||||
- `destination_id` - Custom field for Rajaongkir (added via addon)
|
||||
- + Any custom fields added by addons
|
||||
- `$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
|
||||
/**
|
||||
* Bridge Rajaongkir plugin with WooNooW shipping calculation.
|
||||
*
|
||||
* This code listens for WooNooW's shipping hook and sets the
|
||||
* Rajaongkir session variables it needs to calculate rates.
|
||||
* Plugin Name: WooNooW Rajaongkir Bridge
|
||||
* Description: Adds searchable destination selector for Rajaongkir shipping
|
||||
* Version: 1.0.0
|
||||
* 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 ) {
|
||||
// Only process for Indonesia
|
||||
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_label' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if destination_id is provided by frontend
|
||||
// Set destination from frontend
|
||||
if ( ! empty( $shipping['destination_id'] ) ) {
|
||||
WC()->session->set( 'selected_destination_id', $shipping['destination_id'] );
|
||||
WC()->session->set( 'selected_destination_label', $shipping['destination_label'] ?? $shipping['city'] );
|
||||
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'] );
|
||||
WC()->session->set( 'selected_destination_id', intval( $shipping['destination_id'] ) );
|
||||
WC()->session->set( 'selected_destination_label', sanitize_text_field( $shipping['destination_label'] ?? '' ) );
|
||||
}
|
||||
}, 10, 2 );
|
||||
|
||||
/**
|
||||
* Helper: Lookup Rajaongkir destination by city/state name.
|
||||
*
|
||||
* This is a simplified example. Implement based on your Rajaongkir data structure.
|
||||
* 2. REST API: Search Rajaongkir destinations
|
||||
*/
|
||||
function rajaongkir_lookup_destination( $city, $state ) {
|
||||
// Query the Rajaongkir location database
|
||||
// This depends on how your Rajaongkir plugin stores location data
|
||||
add_action( 'rest_api_init', function() {
|
||||
register_rest_route( 'woonoow/v1', '/rajaongkir/destinations', [
|
||||
'methods' => 'GET',
|
||||
'callback' => 'woonoow_search_rajaongkir_destinations',
|
||||
'permission_callback' => '__return_true',
|
||||
'args' => [
|
||||
'search' => [
|
||||
'required' => true,
|
||||
'type' => 'string',
|
||||
'sanitize_callback' => 'sanitize_text_field',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
// Example using transient/option storage:
|
||||
$locations = get_option( 'cekongkir_destinations', [] );
|
||||
function woonoow_search_rajaongkir_destinations( $request ) {
|
||||
$search = $request->get_param( 'search' );
|
||||
|
||||
foreach ( $locations as $location ) {
|
||||
if (
|
||||
stripos( $location['city'], $city ) !== false ||
|
||||
stripos( $location['district'], $city ) !== false
|
||||
) {
|
||||
return [
|
||||
'id' => $location['id'],
|
||||
'label' => $location['label'],
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
];
|
||||
$fields['destination_selector'] = [
|
||||
'type' => 'custom',
|
||||
'component' => 'RajaongkirDestinationSelector',
|
||||
'label' => __( 'Destination', 'woonoow-rajaongkir' ),
|
||||
'required' => true,
|
||||
];
|
||||
// Fallback: Use options storage
|
||||
if ( empty( $destinations ) ) {
|
||||
$stored = get_option( 'cekongkir_destinations', [] );
|
||||
foreach ( $stored as $id => $label ) {
|
||||
$destinations[] = [
|
||||
'value' => $id,
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $fields;
|
||||
} );
|
||||
```
|
||||
|
||||
### 2. Enqueue JavaScript for Destination Search
|
||||
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(
|
||||
'woonoow-rajaongkir',
|
||||
plugins_url( 'assets/js/woonoow-integration.js', __FILE__ ),
|
||||
[ 'woonoow-admin' ],
|
||||
'woonoow-rajaongkir-bridge',
|
||||
plugin_dir_url( __FILE__ ) . 'rajaongkir-bridge.js',
|
||||
[],
|
||||
'1.0.0',
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script( 'woonoow-rajaongkir', 'WNW_RAJAONGKIR', [
|
||||
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
|
||||
'nonce' => wp_create_nonce( 'rajaongkir_search' ),
|
||||
] );
|
||||
} );
|
||||
wp_localize_script( 'woonoow-rajaongkir-bridge', 'WNW_RAJAONGKIR', [
|
||||
'apiUrl' => rest_url( 'woonoow/v1/rajaongkir/destinations' ),
|
||||
'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
|
||||
|
||||
1. Install and configure Rajaongkir plugin
|
||||
2. Add the code snippet above
|
||||
3. Go to WooNooW → Orders → Create New Order
|
||||
4. Add a product
|
||||
5. Set country to Indonesia
|
||||
6. Fill in city/state (or use destination selector if implemented)
|
||||
1. Install Rajaongkir plugin and configure API key
|
||||
2. Add the bridge code above
|
||||
3. Go to WooNooW Checkout
|
||||
4. Set country to Indonesia
|
||||
5. Type in the destination search field
|
||||
6. Select a destination from dropdown
|
||||
7. Click "Calculate Shipping"
|
||||
8. Verify Rajaongkir rates appear
|
||||
|
||||
@@ -171,29 +471,21 @@ add_action( 'woonoow/admin/enqueue_scripts', function() {
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Rates not appearing?
|
||||
### Destinations not loading?
|
||||
|
||||
1. Check browser DevTools Network tab for `/shipping/calculate` response
|
||||
2. Look for `debug` object in response:
|
||||
```json
|
||||
{
|
||||
"methods": [...],
|
||||
"debug": {
|
||||
"packages_count": 1,
|
||||
"cart_items_count": 1,
|
||||
"address": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Verify Rajaongkir session is set:
|
||||
```php
|
||||
error_log( 'destination_id: ' . WC()->session->get( 'selected_destination_id' ) );
|
||||
```
|
||||
Check the REST API endpoint:
|
||||
```
|
||||
GET /wp-json/woonoow/v1/rajaongkir/destinations?search=jakarta
|
||||
```
|
||||
|
||||
### 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 );
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
17
customer-spa/package-lock.json
generated
17
customer-spa/package-lock.json
generated
@@ -25,6 +25,7 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -3597,6 +3598,22 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.547.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
107
customer-spa/src/components/ui/command.tsx
Normal file
107
customer-spa/src/components/ui/command.tsx
Normal 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,
|
||||
}
|
||||
30
customer-spa/src/components/ui/popover.tsx
Normal file
30
customer-spa/src/components/ui/popover.tsx
Normal 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 }
|
||||
99
customer-spa/src/components/ui/searchable-select.tsx
Normal file
99
customer-spa/src/components/ui/searchable-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useCartStore } from '@/lib/cart/store';
|
||||
import { useCheckoutSettings } from '@/hooks/useAppearanceSettings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import Container from '@/components/Layout/Container';
|
||||
import SEOHead from '@/components/SEOHead';
|
||||
import { formatPrice } from '@/lib/currency';
|
||||
@@ -97,6 +98,62 @@ export default function Checkout() {
|
||||
const [showBillingForm, setShowBillingForm] = 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
|
||||
useEffect(() => {
|
||||
const loadAddresses = async () => {
|
||||
@@ -491,14 +548,24 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
<SearchableSelect
|
||||
options={billingStateOptions}
|
||||
value={billingData.state}
|
||||
onChange={(e) => setBillingData({ ...billingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
onChange={(v) => setBillingData({ ...billingData, state: v })}
|
||||
placeholder={billingStateOptions.length ? "Select state" : "N/A"}
|
||||
disabled={!billingStateOptions.length}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -511,16 +578,6 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</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>
|
||||
@@ -652,14 +709,24 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium mb-2">State / Province *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
<SearchableSelect
|
||||
options={shippingStateOptions}
|
||||
value={shippingData.state}
|
||||
onChange={(e) => setShippingData({ ...shippingData, state: e.target.value })}
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
onChange={(v) => setShippingData({ ...shippingData, state: v })}
|
||||
placeholder={shippingStateOptions.length ? "Select state" : "N/A"}
|
||||
disabled={!shippingStateOptions.length}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -672,16 +739,6 @@ export default function Checkout() {
|
||||
className="w-full border rounded-lg px-4 py-2"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user