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

@@ -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",

View File

@@ -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",

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 { 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>
)}
</>