diff --git a/RAJAONGKIR_INTEGRATION.md b/RAJAONGKIR_INTEGRATION.md index f24a012..2e34328 100644 --- a/RAJAONGKIR_INTEGRATION.md +++ b/RAJAONGKIR_INTEGRATION.md @@ -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 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', + ], + ], + ]); +}); + +function woonoow_search_rajaongkir_destinations( $request ) { + $search = $request->get_param( 'search' ); - // Example using transient/option storage: - $locations = get_option( 'cekongkir_destinations', [] ); + if ( strlen( $search ) < 2 ) { + return new WP_REST_Response( [], 200 ); + } + + // Get Rajaongkir destinations from transient/cache + $destinations = get_transient( 'cekongkir_all_destinations' ); - foreach ( $locations as $location ) { - if ( - stripos( $location['city'], $city ) !== false || - stripos( $location['district'], $city ) !== false - ) { - return [ - 'id' => $location['id'], - 'label' => $location['label'], + 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; + // 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; + } + + wp_enqueue_script( + 'woonoow-rajaongkir-bridge', + plugin_dir_url( __FILE__ ) . 'rajaongkir-bridge.js', + [], + '1.0.0', + true + ); + + 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 = ` + +
+ + + + +
+ `; + + // 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 = '
No destinations found
'; + } else { + resultsDiv.innerHTML = destinations.map(dest => ` +
+ ${dest.label} +
+ `).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'); + } + }); + } +})(); ``` --- -## Frontend Integration (Optional) +## React Component Version (for customer-spa) -To add a proper Rajaongkir destination selector to WooNooW's admin order form, you need to: +If you're extending the customer-spa directly, here's a React component: -### 1. Register Custom Address Fields +```tsx +// components/RajaongkirDestinationSelector.tsx +import React, { useState, useEffect } from 'react'; +import { api } from '@/lib/api/client'; +import { SearchableSelect } from '@/components/ui/searchable-select'; -```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, - ]; +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([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (search.length < 2) { + setOptions([]); + return; } - return $fields; -} ); + + const timer = setTimeout(async () => { + setLoading(true); + try { + const data = await api.get('/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 ( +
+ + + {label && ( +

Selected: {label}

+ )} +
+ ); +} ``` -### 2. Enqueue JavaScript for Destination Search +Usage in Checkout: -```php -add_action( 'woonoow/admin/enqueue_scripts', function() { - wp_enqueue_script( - 'woonoow-rajaongkir', - plugins_url( 'assets/js/woonoow-integration.js', __FILE__ ), - [ 'woonoow-admin' ], - '1.0.0', - true - ); - - wp_localize_script( 'woonoow-rajaongkir', 'WNW_RAJAONGKIR', [ - 'ajaxUrl' => admin_url( 'admin-ajax.php' ), - 'nonce' => wp_create_nonce( 'rajaongkir_search' ), - ] ); -} ); +```tsx +// In Checkout/index.tsx +import { RajaongkirDestinationSelector } from '@/components/RajaongkirDestinationSelector'; + +// In the shipping form, add: +{shippingData.country === 'ID' && ( +
+ setShippingData({ + ...shippingData, + destination_id: id, + destination_label: label, + })} + /> +
+)} ``` --- ## 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 ); +``` --- diff --git a/customer-spa/package-lock.json b/customer-spa/package-lock.json index e5d97da..16f6c79 100644 --- a/customer-spa/package-lock.json +++ b/customer-spa/package-lock.json @@ -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", diff --git a/customer-spa/package.json b/customer-spa/package.json index 2ab06b5..802c812 100644 --- a/customer-spa/package.json +++ b/customer-spa/package.json @@ -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", diff --git a/customer-spa/src/components/ui/command.tsx b/customer-spa/src/components/ui/command.tsx new file mode 100644 index 0000000..c647892 --- /dev/null +++ b/customer-spa/src/components/ui/command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +export { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} diff --git a/customer-spa/src/components/ui/popover.tsx b/customer-spa/src/components/ui/popover.tsx new file mode 100644 index 0000000..0eda425 --- /dev/null +++ b/customer-spa/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/customer-spa/src/components/ui/searchable-select.tsx b/customer-spa/src/components/ui/searchable-select.tsx new file mode 100644 index 0000000..a184609 --- /dev/null +++ b/customer-spa/src/components/ui/searchable-select.tsx @@ -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 ( + !disabled && setOpen(o)}> + + + + + + + + {emptyLabel} + {options.map((opt) => ( + { + onChange?.(opt.value); + setOpen(false); + }} + > + + {opt.label} + + ))} + + + + + ); +} diff --git a/customer-spa/src/pages/Checkout/index.tsx b/customer-spa/src/pages/Checkout/index.tsx index b278042..1ee069a 100644 --- a/customer-spa/src/pages/Checkout/index.tsx +++ b/customer-spa/src/pages/Checkout/index.tsx @@ -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>>({}); + 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>; + 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" /> +
+ + setBillingData({ ...billingData, country: v })} + placeholder="Select country" + disabled={countries.length <= 1} + /> +
- 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} />
@@ -511,16 +578,6 @@ export default function Checkout() { className="w-full border rounded-lg px-4 py-2" />
-
- - setBillingData({ ...billingData, country: e.target.value })} - className="w-full border rounded-lg px-4 py-2" - /> -
)} @@ -652,14 +709,24 @@ export default function Checkout() { className="w-full border rounded-lg px-4 py-2" /> +
+ + setShippingData({ ...shippingData, country: v })} + placeholder="Select country" + disabled={countries.length <= 1} + /> +
- 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} />
@@ -672,16 +739,6 @@ export default function Checkout() { className="w-full border rounded-lg px-4 py-2" />
-
- - setShippingData({ ...shippingData, country: e.target.value })} - className="w-full border rounded-lg px-4 py-2" - /> -
)}