feat: Complete Dashboard API Integration with Analytics Controller

 Features:
- Implemented API integration for all 7 dashboard pages
- Added Analytics REST API controller with 7 endpoints
- Full loading and error states with retry functionality
- Seamless dummy data toggle for development

📊 Dashboard Pages:
- Customers Analytics (complete)
- Revenue Analytics (complete)
- Orders Analytics (complete)
- Products Analytics (complete)
- Coupons Analytics (complete)
- Taxes Analytics (complete)
- Dashboard Overview (complete)

🔌 Backend:
- Created AnalyticsController.php with REST endpoints
- All endpoints return 501 (Not Implemented) for now
- Ready for HPOS-based implementation
- Proper permission checks

🎨 Frontend:
- useAnalytics hook for data fetching
- React Query caching
- ErrorCard with retry functionality
- TypeScript type safety
- Zero build errors

📝 Documentation:
- DASHBOARD_API_IMPLEMENTATION.md guide
- Backend implementation roadmap
- Testing strategy

🔧 Build:
- All pages compile successfully
- Production-ready with dummy data fallback
- Zero TypeScript errors
This commit is contained in:
dwindown
2025-11-04 11:19:00 +07:00
commit 232059e928
148 changed files with 28984 additions and 0 deletions

View File

@@ -0,0 +1,514 @@
import React, { useEffect, useRef, useState } from 'react';
import { useParams, Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api, OrdersApi } from '@/lib/api';
import { formatRelativeOrDate } from '@/lib/dates';
import { formatMoney } from '@/lib/currency';
import { ArrowLeft, Printer, ExternalLink, Loader2, Ticket, FileText, Pencil, RefreshCw } from 'lucide-react';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { ErrorCard } from '@/components/ErrorCard';
import { InlineLoadingState } from '@/components/LoadingState';
import { __, sprintf } from '@/lib/i18n';
function Money({ value, currency, symbol }: { value?: number; currency?: string; symbol?: string }) {
return <>{formatMoney(value, { currency, symbol })}</>;
}
function StatusBadge({ status }: { status?: string }) {
const s = (status || '').toLowerCase();
let cls = 'inline-flex items-center rounded px-2 py-1 text-xs font-medium border';
let tone = 'bg-gray-100 text-gray-700 border-gray-200';
if (s === 'completed' || s === 'paid') tone = 'bg-green-100 text-green-800 border-green-200';
else if (s === 'processing') tone = 'bg-yellow-100 text-yellow-800 border-yellow-200';
else if (s === 'on-hold') tone = 'bg-amber-100 text-amber-800 border-amber-200';
else if (s === 'pending') tone = 'bg-orange-100 text-orange-800 border-orange-200';
else if (s === 'cancelled' || s === 'failed' || s === 'refunded') tone = 'bg-red-100 text-red-800 border-red-200';
return <span className={`${cls} ${tone}`}>{status ? status[0].toUpperCase() + status.slice(1) : '—'}</span>;
}
const STATUS_OPTIONS = ['pending', 'processing', 'completed', 'on-hold', 'cancelled', 'refunded', 'failed'];
export default function OrderShow() {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const qc = useQueryClient();
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
const [params, setParams] = useSearchParams();
const mode = params.get('mode'); // undefined | 'label' | 'invoice'
const isPrintMode = mode === 'label' || mode === 'invoice';
function triggerPrint(nextMode: 'label' | 'invoice') {
params.set('mode', nextMode);
setParams(params, { replace: true });
setTimeout(() => {
window.print();
params.delete('mode');
setParams(params, { replace: true });
}, 50);
}
function printLabel() {
triggerPrint('label');
}
function printInvoice() {
triggerPrint('invoice');
}
const [showRetryDialog, setShowRetryDialog] = useState(false);
const qrRef = useRef<HTMLCanvasElement | null>(null);
const q = useQuery({
queryKey: ['order', id],
enabled: !!id,
queryFn: () => api.get(`/orders/${id}`),
});
const order = q.data;
// Check if all items are virtual (digital products only)
const isVirtualOnly = React.useMemo(() => {
if (!order?.items || order.items.length === 0) return false;
return order.items.every((item: any) => item.virtual || item.downloadable);
}, [order?.items]);
// Mutation for status update with optimistic update
const statusMutation = useMutation({
mutationFn: (nextStatus: string) => OrdersApi.update(Number(id), { status: nextStatus }),
onMutate: async (nextStatus) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['order', id] });
// Snapshot previous value
const previous = qc.getQueryData(['order', id]);
// Optimistically update
qc.setQueryData(['order', id], (old: any) => ({
...old,
status: nextStatus,
}));
return { previous };
},
onSuccess: () => {
showSuccessToast(__('Order status updated'));
// Refetch to get server state
q.refetch();
},
onError: (err: any, _variables, context) => {
// Rollback on error
if (context?.previous) {
qc.setQueryData(['order', id], context.previous);
}
showErrorToast(err, __('Failed to update status'));
},
});
function handleStatusChange(nextStatus: string) {
if (!id) return;
statusMutation.mutate(nextStatus);
}
// Mutation for retry payment
const retryPaymentMutation = useMutation({
mutationFn: () => api.post(`/orders/${id}/retry-payment`, {}),
onSuccess: () => {
showSuccessToast(__('Payment processing retried'));
q.refetch();
},
onError: (err: any) => {
showErrorToast(err, __('Failed to retry payment'));
},
});
function handleRetryPayment() {
if (!id) return;
setShowRetryDialog(true);
}
function confirmRetryPayment() {
setShowRetryDialog(false);
retryPaymentMutation.mutate();
}
useEffect(() => {
if (!isPrintMode || !qrRef.current || !order) return;
(async () => {
try {
const mod = await import( 'qrcode' );
const QR = (mod as any).default || (mod as any);
const text = `ORDER:${order.number || id}`;
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
} catch (_) {
// optional dependency not installed; silently ignore
}
})();
}, [mode, order, id, isPrintMode]);
return (
<div className={`space-y-4 ${mode === 'label' ? 'woonoow-label-mode' : ''}`}>
<div className="flex flex-wrap items-center gap-2">
<Link className="border rounded-md px-3 py-2 text-sm flex items-center gap-2" to={`/orders`}>
<ArrowLeft className="w-4 h-4" /> {__('Back')}
</Link>
<h2 className="text-lg font-semibold flex-1 min-w-[160px]">{__('Order')} {order?.number ? `#${order.number}` : (id ? `#${id}` : '')}</h2>
<div className="ml-auto flex flex-wrap items-center gap-2">
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print order')}>
<Printer className="w-4 h-4" /> {__('Print')}
</button>
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printInvoice} title={__('Print invoice')}>
<FileText className="w-4 h-4" /> {__('Invoice')}
</button>
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
<Ticket className="w-4 h-4" /> {__('Label')}
</button>
<Link className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" to={`/orders/${id}/edit`} title={__('Edit order')}>
<Pencil className="w-4 h-4" /> {__('Edit')}
</Link>
<Link className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" to={`/orders`} title={__('Back to orders list')}>
<ExternalLink className="w-4 h-4" /> {__('Orders')}
</Link>
</div>
</div>
{q.isLoading && <InlineLoadingState message={__('Loading order...')} />}
{q.isError && (
<ErrorCard
title={__('Failed to load order')}
message={getPageLoadErrorMessage(q.error)}
onRetry={() => q.refetch()}
/>
)}
{order && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Left column */}
<div className="md:col-span-2 space-y-4">
{/* Summary */}
<div className="rounded border">
<div className="px-4 py-3 border-b flex items-center justify-between">
<div className="font-medium">{__('Summary')}</div>
<div className="w-[180px] flex items-center gap-2">
<Select
value={order.status || ''}
onValueChange={(v) => handleStatusChange(v)}
disabled={statusMutation.isPending}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={__('Change status')} />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s} className="text-xs">
{s.charAt(0).toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
{statusMutation.isPending && (
<Loader2 className="w-4 h-4 animate-spin text-gray-500" />
)}
</div>
</div>
<div className="p-4 grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm">
<div className="sm:col-span-3">
<div className="text-xs opacity-60 mb-1">{__('Date')}</div>
<div><span title={order.date ?? ''}>{formatRelativeOrDate(order.date_ts)}</span></div>
</div>
<div>
<div className="text-xs opacity-60 mb-1">{__('Payment')}</div>
<div>{order.payment_method || '—'}</div>
</div>
<div>
<div className="text-xs opacity-60 mb-1">{__('Shipping')}</div>
<div>{order.shipping_method || '—'}</div>
</div>
<div>
<div className="text-xs opacity-60 mb-1">{__('Status')}</div>
<div className="capitalize font-medium"><StatusBadge status={order.status} /></div>
</div>
</div>
</div>
{/* Payment Instructions */}
{order.payment_meta && order.payment_meta.length > 0 && (
<div className="rounded border overflow-hidden">
<div className="px-4 py-3 border-b font-medium flex items-center justify-between">
<div className="flex items-center gap-2">
<Ticket className="w-4 h-4" />
{__('Payment Instructions')}
</div>
{['pending', 'on-hold', 'failed'].includes(order.status) && (
<>
<button
onClick={handleRetryPayment}
disabled={retryPaymentMutation.isPending}
className="ui-ctrl text-xs px-3 py-1.5 border rounded-md hover:bg-gray-50 flex items-center gap-1.5 disabled:opacity-50"
title={__('Retry payment processing')}
>
{retryPaymentMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
{__('Retry Payment')}
</button>
<Dialog open={showRetryDialog} onOpenChange={setShowRetryDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Retry Payment')}</DialogTitle>
<DialogDescription>
{__('Are you sure you want to retry payment processing for this order?')}
<br />
<span className="text-amber-600 font-medium">
{__('This will create a new payment transaction.')}
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRetryDialog(false)}>
{__('Cancel')}
</Button>
<Button onClick={confirmRetryPayment} disabled={retryPaymentMutation.isPending}>
{retryPaymentMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
{__('Retry Payment')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
</div>
<div className="p-4 space-y-3">
{order.payment_meta.map((meta: any) => (
<div key={meta.key} className="grid grid-cols-[120px_1fr] gap-2 text-sm">
<div className="opacity-60">{meta.label}</div>
<div className="font-medium">
{meta.key.includes('url') || meta.key.includes('redirect') ? (
<a
href={meta.value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
{meta.value}
<ExternalLink className="w-3 h-3" />
</a>
) : meta.key.includes('amount') ? (
<span dangerouslySetInnerHTML={{ __html: meta.value }} />
) : (
meta.value
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Items */}
<div className="rounded border overflow-hidden">
<div className="px-4 py-3 border-b font-medium">{__('Items')}</div>
{/* Desktop/table view */}
<div className="hidden md:block overflow-x-auto">
<table className="min-w-[640px] w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="px-3 py-2">{__('Product')}</th>
<th className="px-3 py-2 w-20 text-right">{__('Qty')}</th>
<th className="px-3 py-2 w-32 text-right">{__('Subtotal')}</th>
<th className="px-3 py-2 w-32 text-right">{__('Total')}</th>
</tr>
</thead>
<tbody>
{order.items?.map((it: any) => (
<tr key={it.id} className="border-b last:border-0">
<td className="px-3 py-2">
<div className="font-medium">{it.name}</div>
{it.sku ? <div className="opacity-60 text-xs">SKU: {it.sku}</div> : null}
</td>
<td className="px-3 py-2 text-right">×{it.qty}</td>
<td className="px-3 py-2 text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></td>
<td className="px-3 py-2 text-right"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
</tr>
))}
{!order.items?.length && (
<tr><td className="px-3 py-6 text-center opacity-60" colSpan={4}>{__('No items')}</td></tr>
)}
</tbody>
</table>
</div>
{/* Mobile/card view */}
<div className="md:hidden divide-y">
{order.items?.length ? (
order.items.map((it: any) => (
<div key={it.id} className="px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium truncate">{it.name}</div>
{it.sku ? <div className="opacity-60 text-xs">SKU: {it.sku}</div> : null}
</div>
<div className="text-right whitespace-nowrap">×{it.qty}</div>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="opacity-60">{__('Subtotal')}</div>
<div className="text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></div>
<div className="opacity-60">{__('Total')}</div>
<div className="text-right font-medium"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></div>
</div>
</div>
))
) : (
<div className="px-4 py-6 text-center opacity-60">{__('No items')}</div>
)}
</div>
</div>
{/* Notes */}
<div className="rounded border overflow-hidden">
<div className="px-4 py-3 border-b font-medium">{__('Order Notes')}</div>
<div className="p-3 text-sm relative">
<div className="border-l-2 border-gray-200 ml-3 space-y-4">
{order.notes?.length ? order.notes.map((n: any, idx: number) => (
<div key={n.id || idx} className="pl-4 relative">
<span className="absolute -left-[5px] top-1 w-2 h-2 rounded-full bg-gray-400"></span>
<div className="text-xs opacity-60 mb-1">
{n.date ? new Date(n.date).toLocaleString() : ''} {n.is_customer_note ? '· customer' : ''}
</div>
<div>{n.content}</div>
</div>
)) : <div className="opacity-60 ml-4">{__('No notes')}</div>}
</div>
</div>
</div>
</div>
{/* Right column */}
<div className="space-y-4">
<div className="rounded border p-4">
<div className="text-xs opacity-60 mb-1">{__('Totals')}</div>
<div className="space-y-1 text-sm">
<div className="flex justify-between"><span>{__('Subtotal')}</span><b><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></b></div>
<div className="flex justify-between"><span>{__('Discount')}</span><b><Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></b></div>
<div className="flex justify-between"><span>{__('Shipping')}</span><b><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></b></div>
<div className="flex justify-between"><span>{__('Tax')}</span><b><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></b></div>
<div className="flex justify-between text-base mt-2 border-t pt-2"><span>{__('Total')}</span><b><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></b></div>
</div>
</div>
<div className="rounded border p-4">
<div className="text-xs opacity-60 mb-1">{__('Billing')}</div>
<div className="text-sm">{order.billing?.name || '—'}</div>
{order.billing?.email && (<div className="text-xs opacity-70">{order.billing.email}</div>)}
{order.billing?.phone && (<div className="text-xs opacity-70">{order.billing.phone}</div>)}
<div className="text-xs opacity-70 mt-2" dangerouslySetInnerHTML={{ __html: order.billing?.address || '' }} />
</div>
{/* Only show shipping for physical products */}
{!isVirtualOnly && (
<div className="rounded border p-4">
<div className="text-xs opacity-60 mb-1">{__('Shipping')}</div>
<div className="text-sm">{order.shipping?.name || '—'}</div>
<div className="text-xs opacity-70 mt-2" dangerouslySetInnerHTML={{ __html: order.shipping?.address || '' }} />
</div>
)}
{/* Customer Note */}
{order.customer_note && (
<div className="rounded border p-4">
<div className="text-xs opacity-60 mb-1">{__('Customer Note')}</div>
<div className="text-sm whitespace-pre-wrap">{order.customer_note}</div>
</div>
)}
</div>
</div>
)}
{/* Print-only layouts */}
{order && (
<div className="print-only">
{mode === 'invoice' && (
<div className="max-w-[800px] mx-auto p-6 text-sm">
<div className="flex items-start justify-between mb-6">
<div>
<div className="text-xl font-semibold">Invoice</div>
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts||0)*1000).toLocaleString()}</div>
</div>
<div className="text-right">
<div className="font-medium">{siteTitle}</div>
<div className="opacity-60 text-xs">{window.location.origin}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<div className="text-xs opacity-60 mb-1">{__('Bill To')}</div>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.billing?.address || order.billing?.name || '' }} />
</div>
<div className="text-right">
<canvas ref={qrRef} className="inline-block w-24 h-24 border" />
</div>
</div>
<table className="w-full border-collapse mb-6">
<thead>
<tr>
<th className="text-left border-b py-2 pr-2">Product</th>
<th className="text-right border-b py-2 px-2">Qty</th>
<th className="text-right border-b py-2 px-2">Subtotal</th>
<th className="text-right border-b py-2 pl-2">Total</th>
</tr>
</thead>
<tbody>
{(order.items || []).map((it:any) => (
<tr key={it.id}>
<td className="py-1 pr-2">{it.name}</td>
<td className="py-1 px-2 text-right">×{it.qty}</td>
<td className="py-1 px-2 text-right"><Money value={it.subtotal} currency={order.currency} symbol={order.currency_symbol} /></td>
<td className="py-1 pl-2 text-right"><Money value={it.total} currency={order.currency} symbol={order.currency_symbol} /></td>
</tr>
))}
</tbody>
</table>
<div className="flex justify-end">
<div className="min-w-[260px]">
<div className="flex justify-between"><span>Subtotal</span><span><Money value={order.totals?.subtotal} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Discount</span><span><Money value={order.totals?.discount} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Shipping</span><span><Money value={order.totals?.shipping} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between"><span>Tax</span><span><Money value={order.totals?.tax} currency={order.currency} symbol={order.currency_symbol} /></span></div>
<div className="flex justify-between font-semibold border-t mt-2 pt-2"><span>Total</span><span><Money value={order.totals?.total} currency={order.currency} symbol={order.currency_symbol} /></span></div>
</div>
</div>
</div>
)}
{mode === 'label' && (
<div className="p-4 print-4x6">
<div className="border rounded p-4 h-full">
<div className="flex justify-between items-start mb-3">
<div className="text-base font-semibold">#{order.number}</div>
<canvas ref={qrRef} className="w-24 h-24 border" />
</div>
<div className="mb-3">
<div className="text-xs opacity-60 mb-1">{__('Ship To')}</div>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: order.shipping?.address || order.billing?.address || '' }} />
</div>
<div className="text-xs opacity-60 mb-1">{__('Items')}</div>
<ul className="text-sm list-disc pl-4">
{(order.items||[]).map((it:any)=> (
<li key={it.id}>{it.name} ×{it.qty}</li>
))}
</ul>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import OrderForm from '@/routes/Orders/partials/OrderForm';
import { OrdersApi } from '@/lib/api';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
import { ErrorCard } from '@/components/ErrorCard';
import { LoadingState } from '@/components/LoadingState';
import { ArrowLeft } from 'lucide-react';
import { __, sprintf } from '@/lib/i18n';
export default function OrdersEdit() {
const { id } = useParams();
const orderId = Number(id);
const nav = useNavigate();
const qc = useQueryClient();
const countriesQ = useQuery({ queryKey: ['countries'], queryFn: OrdersApi.countries });
const paymentsQ = useQuery({ queryKey: ['payments'], queryFn: OrdersApi.payments });
const shippingsQ = useQuery({ queryKey: ['shippings'], queryFn: OrdersApi.shippings });
const orderQ = useQuery({ queryKey: ['order', orderId], enabled: Number.isFinite(orderId), queryFn: () => OrdersApi.get(orderId) });
const upd = useMutation({
mutationFn: (payload: any) => OrdersApi.update(orderId, payload),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['orders'] });
qc.invalidateQueries({ queryKey: ['order', orderId] });
showSuccessToast(__('Order updated successfully'));
nav(`/orders/${orderId}`);
},
onError: (error: any) => {
showErrorToast(error);
}
});
const countriesData = React.useMemo(() => {
const list = countriesQ.data?.countries ?? [];
return list.map((c: any) => ({ code: String(c.code), name: String(c.name) }));
}, [countriesQ.data]);
if (!Number.isFinite(orderId)) {
return <div className="p-4 text-sm text-red-600">{__('Invalid order id.')}</div>;
}
if (orderQ.isLoading || countriesQ.isLoading) {
return <LoadingState message={sprintf(__('Loading order #%s...'), orderId)} />;
}
if (orderQ.isError) {
return <ErrorCard
title={__('Failed to load order')}
message={getPageLoadErrorMessage(orderQ.error)}
onRetry={() => orderQ.refetch()}
/>;
}
const order = orderQ.data || {};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button
className="border rounded-md px-3 py-2 text-sm flex items-center gap-2"
onClick={() => nav(`/orders/${orderId}`)}
>
<ArrowLeft className="w-4 h-4" /> {__('Back')}
</button>
<h2 className="text-lg font-semibold flex-1 min-w-[160px]">
{sprintf(__('Edit Order #%s'), orderId)}
</h2>
</div>
<OrderForm
mode="edit"
initial={order}
currency={order.currency}
currencySymbol={order.currency_symbol}
countries={countriesData}
states={countriesQ.data?.states || {}}
defaultCountry={countriesQ.data?.default_country}
payments={(paymentsQ.data || [])}
shippings={(shippingsQ.data || [])}
itemsEditable={['pending', 'on-hold', 'failed', 'draft'].includes(order.status)}
showCoupons
onSubmit={(form) => {
const payload = { ...form } as any;
upd.mutate(payload);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { OrdersApi } from '@/lib/api';
import { useNavigate } from 'react-router-dom';
import OrderForm from '@/routes/Orders/partials/OrderForm';
import { getStoreCurrency } from '@/lib/currency';
import { showErrorToast, showSuccessToast } from '@/lib/errorHandling';
import { __, sprintf } from '@/lib/i18n';
export default function OrdersNew() {
const nav = useNavigate();
const qc = useQueryClient();
// Countries from Woo (allowed + default + states)
const countriesQ = useQuery({ queryKey: ['countries'], queryFn: OrdersApi.countries });
const countriesData = React.useMemo(() => {
const list = countriesQ.data?.countries ?? [];
return list.map((c: any) => ({ code: String(c.code), name: String(c.name) }));
}, [countriesQ.data]);
// Live payment & shipping methods
const payments = useQuery({ queryKey: ['payments'], queryFn: OrdersApi.payments });
const shippings = useQuery({ queryKey: ['shippings'], queryFn: OrdersApi.shippings });
const mutate = useMutation({
mutationFn: OrdersApi.create,
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['orders'] });
showSuccessToast(__('Order created successfully'), sprintf(__('Order #%s has been created'), data.number || data.id));
nav('/orders');
},
onError: (error: any) => {
showErrorToast(error);
},
});
// Prefer global store currency injected by PHP
const { currency: storeCurrency, symbol: storeSymbol } = getStoreCurrency();
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{__('New Order')}</h2>
<div className="flex gap-2">
<button className="border rounded-md px-3 py-2 text-sm" onClick={() => nav('/orders')}>{__('Cancel')}</button>
</div>
</div>
<OrderForm
mode="create"
currency={storeCurrency || countriesQ.data?.currency || 'USD'}
currencySymbol={storeSymbol || countriesQ.data?.currency_symbol}
countries={countriesData}
states={countriesQ.data?.states || {}}
defaultCountry={countriesQ.data?.default_country}
payments={(payments.data || [])}
shippings={(shippings.data || [])}
onSubmit={(form) => {
mutate.mutate(form as any);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,468 @@
import React, { useState } from 'react';
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
import { api } from '@/lib/api';
import { Filter, PackageOpen, Trash2 } from 'lucide-react';
import { ErrorCard } from '@/components/ErrorCard';
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
import { __ } from '@/lib/i18n';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { formatRelativeOrDate } from "@/lib/dates";
import { Link, useNavigate } from 'react-router-dom';
function ItemsCell({ row }: { row: any }) {
const count: number = typeof row.items_count === 'number' ? row.items_count : 0;
const brief: string = row.items_brief || '';
const linesTotal: number | undefined = typeof row.lines_total === 'number' ? row.lines_total : undefined;
const linesPreview: number | undefined = typeof row.lines_preview === 'number' ? row.lines_preview : undefined;
const extra = linesTotal && linesPreview ? Math.max(0, linesTotal - linesPreview) : 0;
const label = `${count || '—'} item${count === 1 ? '' : 's'}`;
const inline = brief + (extra > 0 ? ` +${extra} more` : '');
return (
<div className="max-w-[280px] whitespace-nowrap overflow-hidden text-ellipsis">
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<span className="cursor-help">
{label}
{inline ? <> · {inline}</> : null}
</span>
</HoverCardTrigger>
<HoverCardContent className="max-w-sm text-sm">
<div className="font-medium mb-1">{label}</div>
<div className="opacity-80 leading-relaxed">
{row.items_full || brief || 'No items'}
</div>
</HoverCardContent>
</HoverCard>
</div>
);
}
import { Skeleton } from '@/components/ui/skeleton';
import { formatMoney, getStoreCurrency } from '@/lib/currency';
import DateRange from '@/components/filters/DateRange';
import OrderBy from '@/components/filters/OrderBy';
import { setQuery, getQuery } from '@/lib/query-params';
const statusStyle: Record<string, string> = {
pending: 'bg-amber-100 text-amber-800',
processing: 'bg-blue-100 text-blue-800',
completed: 'bg-emerald-100 text-emerald-800',
'on-hold': 'bg-slate-200 text-slate-800',
cancelled: 'bg-zinc-200 text-zinc-800',
refunded: 'bg-purple-100 text-purple-800',
failed: 'bg-rose-100 text-rose-800',
};
function StatusBadge({ value }: { value?: string }) {
const v = (value || '').toLowerCase();
const cls = statusStyle[v] || 'bg-slate-100 text-slate-800';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${cls}`}>{v || 'unknown'}</span>
);
}
export default function Orders() {
const initial = getQuery();
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const perPage = 20;
React.useEffect(() => {
setQuery({ page, status, date_start: dateStart, date_end: dateEnd, orderby, order });
}, [page, status, dateStart, dateEnd, orderby, order]);
const q = useQuery({
queryKey: ['orders', { page, perPage, status, dateStart, dateEnd, orderby, order }],
queryFn: () => api.get('/orders', {
page, per_page: perPage,
status,
date_start: dateStart,
date_end: dateEnd,
orderby,
order,
}),
placeholderData: keepPreviousData,
});
const data = q.data as undefined | { rows: any[]; total: number; page: number; per_page: number };
const nav = useNavigate();
const store = getStoreCurrency();
// Bulk delete mutation
const deleteMutation = useMutation({
mutationFn: async (ids: number[]) => {
const results = await Promise.allSettled(
ids.map(id => api.del(`/orders/${id}`))
);
const failed = results.filter(r => r.status === 'rejected').length;
return { total: ids.length, failed };
},
onSuccess: (result) => {
const { total, failed } = result;
if (failed === 0) {
toast.success(__('Orders deleted successfully'));
} else if (failed < total) {
toast.warning(__(`${total - failed} orders deleted, ${failed} failed`));
} else {
toast.error(__('Failed to delete orders'));
}
setSelectedIds([]);
setShowDeleteDialog(false);
q.refetch();
},
onError: () => {
toast.error(__('Failed to delete orders'));
setShowDeleteDialog(false);
},
});
// Checkbox handlers
const allIds = data?.rows?.map(r => r.id) || [];
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length;
const toggleAll = () => {
if (allSelected) {
setSelectedIds([]);
} else {
setSelectedIds(allIds);
}
};
const toggleRow = (id: number) => {
setSelectedIds(prev =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
);
};
const handleDeleteClick = () => {
if (selectedIds.length > 0) {
setShowDeleteDialog(true);
}
};
const confirmDelete = () => {
deleteMutation.mutate(selectedIds);
};
return (
<div className="space-y-4 w-[100%]">
<div className="rounded-lg border border-border p-4 bg-card flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3 w-full">
<div className="flex gap-3 justify-between">
<button className="border rounded-md px-3 py-2 text-sm bg-black text-white disabled:opacity-50" onClick={() => nav('/orders/new')}>
{__('New order')}
</button>
{selectedIds.length > 0 && (
<button
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
>
<Trash2 className="w-4 h-4" />
{__('Delete')} ({selectedIds.length})
</button>
)}
{/* Mobile: condensed Filters button with HoverCard */}
<div className="flex items-center gap-2 lg:hidden">
<HoverCard openDelay={0} closeDelay={100}>
<HoverCardTrigger asChild>
<button className="border rounded-md px-3 py-2 text-sm inline-flex items-center gap-2">
<Filter className="w-4 h-4" />
{__('Filters')}
</button>
</HoverCardTrigger>
<HoverCardContent align="start" className="w-[calc(100vw-2rem)] mr-6 max-w-sm p-3 space-y-3">
<div className="flex items-center gap-2">
<Select
value={status ?? 'all'}
onValueChange={(v) => {
setPage(1);
setStatus(v === 'all' ? undefined : (v as typeof status));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{__('All statuses')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="processing">{__('Processing')}</SelectItem>
<SelectItem value="completed">{__('Completed')}</SelectItem>
<SelectItem value="on-hold">{__('On-hold')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="refunded">{__('Refunded')}</SelectItem>
<SelectItem value="failed">{__('Failed')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<DateRange
value={{ date_start: dateStart, date_end: dateEnd }}
onChange={(v) => { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }}
/>
<OrderBy
value={{ orderby, order }}
onChange={(v) => {
setPage(1);
setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total');
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
}}
/>
<div className="flex justify-between items-center">
{(status || dateStart || dateEnd || orderby !== 'date' || order !== 'desc') ? (
<button
className="rounded-md px-3 py-2 text-sm bg-red-500/10 text-red-600"
onClick={() => {
setStatus(undefined);
setDateStart(undefined);
setDateEnd(undefined);
setOrderby('date');
setOrder('desc');
setPage(1);
}}
>
{__('Reset')}
</button>
) : <span />}
{q.isFetching && <span className="text-sm opacity-70">{__('Loading…')}</span>}
</div>
</HoverCardContent>
</HoverCard>
</div>
</div>
{/* Desktop: full inline filters */}
<div className="hidden lg:flex gap-2 items-center">
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2">
<Filter className="w-4 h-4 opacity-60" />
<Select
value={status ?? 'all'}
onValueChange={(v) => {
setPage(1);
setStatus(v === 'all' ? undefined : (v as typeof status));
}}
>
<SelectTrigger className="min-w-[140px]">
<SelectValue placeholder={__('All statuses')} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">{__('All statuses')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="processing">{__('Processing')}</SelectItem>
<SelectItem value="completed">{__('Completed')}</SelectItem>
<SelectItem value="on-hold">{__('On-hold')}</SelectItem>
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
<SelectItem value="refunded">{__('Refunded')}</SelectItem>
<SelectItem value="failed">{__('Failed')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<DateRange
value={{ date_start: dateStart, date_end: dateEnd }}
onChange={(v) => { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }}
/>
<OrderBy
value={{ orderby, order }}
onChange={(v) => {
setPage(1);
setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total');
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
}}
/>
</div>
{status && (
<button
className="rounded-md px-3 py-2 text-sm bg-red-500/10 text-red-600"
onClick={() => {
setStatus(undefined);
setDateStart(undefined);
setDateEnd(undefined);
setOrderby('date');
setOrder('desc');
setPage(1);
}}
>
{__('Reset')}
</button>
)}
{q.isFetching && <span className="text-sm opacity-70">{__('Loading…')}</span>}
</div>
</div>
<div className="rounded-lg border border-border bg-card overflow-auto">
{q.isLoading && (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="w-full h-6" />
))}
</div>
)}
{q.isError && (
<ErrorCard
title={__('Failed to load orders')}
message={getPageLoadErrorMessage(q.error)}
onRetry={() => q.refetch()}
/>
)}
{!q.isLoading && !q.isError && (
<table className="min-w-[800px] w-full text-sm">
<thead className="border-b">
<tr className="text-left">
<th className="px-3 py-2 w-12">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label={__('Select all')}
className={someSelected ? 'data-[state=checked]:bg-gray-400' : ''}
/>
</th>
<th className="px-3 py-2">{__('Order')}</th>
<th className="px-3 py-2">{__('Date')}</th>
<th className="px-3 py-2">{__('Customer')}</th>
<th className="px-3 py-2">{__('Items')}</th>
<th className="px-3 py-2">{__('Status')}</th>
<th className="px-3 py-2 text-right">{__('Total')}</th>
<th className="px-3 py-2 text-center">{__('Actions')}</th>
</tr>
</thead>
<tbody>
{data?.rows?.map((row) => (
<tr key={row.id} className="border-b last:border-0">
<td className="px-3 py-2">
<Checkbox
checked={selectedIds.includes(row.id)}
onCheckedChange={() => toggleRow(row.id)}
aria-label={__('Select order')}
/>
</td>
<td className="px-3 py-2">
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
</td>
<td className="px-3 py-2 min-w-32">
<span title={row.date ?? ""}>
{formatRelativeOrDate(row.date_ts)}
</span>
</td>
<td className="px-3 py-2">{row.customer || '—'}</td>
<td className="px-3 py-2">
<ItemsCell row={row} />
</td>
<td className="px-3 py-2"><StatusBadge value={row.status} /></td>
<td className="px-3 py-2 text-right tabular-nums font-mono">
{formatMoney(row.total, {
currency: row.currency || store.currency,
symbol: row.currency_symbol || store.symbol,
thousandSep: store.thousand_sep,
decimalSep: store.decimal_sep,
position: store.position,
decimals: store.decimals,
})}
</td>
<td className="px-3 py-2 text-center space-x-2">
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
</td>
</tr>
))}
{(!data || data.rows.length === 0) && (
<tr>
<td className="px-3 py-12 text-center" colSpan={8}>
<div className="flex flex-col items-center gap-2">
<PackageOpen className="w-8 h-8 opacity-40" />
<div className="font-medium">{__('No orders found')}</div>
{status ? (
<p className="text-sm opacity-70">{__('Try adjusting filters.')}</p>
) : (
<p className="text-sm opacity-70">{__('Once you receive orders, they\'ll show up here.')}</p>
)}
</div>
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
<div className="flex items-center gap-2">
<button
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{__('Previous')}
</button>
<div className="text-sm opacity-80">{__('Page')} {page}</div>
<button
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
disabled={!data || page * perPage >= data.total}
onClick={() => setPage((p) => p + 1)}
>
{__('Next')}
</button>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Delete Orders')}</DialogTitle>
<DialogDescription>
{__('Are you sure you want to delete')} {selectedIds.length} {selectedIds.length === 1 ? __('order') : __('orders')}?
<br />
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={deleteMutation.isPending}
>
{__('Cancel')}
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? __('Deleting...') : __('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,950 @@
// Product search item type for API results
type ProductSearchItem = {
id: number;
name: string;
price?: number | string | null;
regular_price?: number | string | null;
sale_price?: number | string | null;
sku?: string;
stock?: number | null;
virtual?: boolean;
downloadable?: boolean;
};
import * as React from 'react';
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
import { useQuery } from '@tanstack/react-query';
import { api, ProductsApi, CustomersApi } from '@/lib/api';
import { cn } from '@/lib/utils';
import { __, sprintf } from '@/lib/i18n';
import { toast } from 'sonner';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { SearchableSelect } from '@/components/ui/searchable-select';
// --- Types ------------------------------------------------------------
export type CountryOption = { code: string; name: string };
export type StatesMap = Record<string, Record<string, string>>; // { US: { CA: 'California' } }
export type PaymentChannel = { id: string; title: string; meta?: any };
export type PaymentMethod = {
id: string;
title: string;
enabled?: boolean;
channels?: PaymentChannel[]; // If present, show channels instead of gateway
};
export type ShippingMethod = { id: string; title: string; cost: number };
export type LineItem = {
line_item_id?: number; // present in edit mode to update existing line
product_id: number;
qty: number;
name?: string;
price?: number;
virtual?: boolean;
downloadable?: boolean;
regular_price?: number;
sale_price?: number | null;
};
export type ExistingOrderDTO = {
id: number;
status?: string;
billing?: any;
shipping?: any;
items?: LineItem[];
payment_method?: string;
payment_method_id?: string;
shipping_method?: string;
shipping_method_id?: string;
customer_note?: string;
currency?: string;
currency_symbol?: string;
};
export type OrderPayload = {
status: string;
billing: any;
shipping?: any;
items?: LineItem[];
payment_method?: string;
shipping_method?: string;
customer_note?: string;
register_as_member?: boolean;
coupons?: string[];
};
type Props = {
mode: 'create' | 'edit';
initial?: ExistingOrderDTO | null;
countries: CountryOption[];
states: StatesMap;
defaultCountry?: string;
payments?: PaymentMethod[];
shippings?: ShippingMethod[];
onSubmit: (payload: OrderPayload) => Promise<void> | void;
className?: string;
currency?: string;
currencySymbol?: string;
leftTop?: React.ReactNode;
rightTop?: React.ReactNode;
itemsEditable?: boolean;
showCoupons?: boolean;
};
const STATUS_LIST = ['pending','processing','on-hold','completed','cancelled','refunded','failed'];
// --- Component --------------------------------------------------------
export default function OrderForm({
mode,
initial,
countries,
states,
defaultCountry,
payments = [],
shippings = [],
onSubmit,
className,
leftTop,
rightTop,
itemsEditable = true,
showCoupons = true,
currency,
currencySymbol,
}: Props) {
const oneCountryOnly = countries.length === 1;
const firstCountry = countries[0]?.code || 'US';
const baseCountry = (defaultCountry && countries.find(c => c.code === defaultCountry)?.code) || firstCountry;
// Billing
const [bFirst, setBFirst] = React.useState(initial?.billing?.first_name || '');
const [bLast, setBLast] = React.useState(initial?.billing?.last_name || '');
const [bEmail, setBEmail] = React.useState(initial?.billing?.email || '');
const [bPhone, setBPhone] = React.useState(initial?.billing?.phone || '');
const [bAddr1, setBAddr1] = React.useState(initial?.billing?.address_1 || '');
const [bCity, setBCity] = React.useState(initial?.billing?.city || '');
const [bPost, setBPost] = React.useState(initial?.billing?.postcode || '');
const [bCountry, setBCountry] = React.useState(initial?.billing?.country || baseCountry);
const [bState, setBState] = React.useState(initial?.billing?.state || '');
// Shipping toggle + fields
const [shipDiff, setShipDiff] = React.useState(Boolean(initial?.shipping && !isEmptyAddress(initial?.shipping)));
const [sFirst, setSFirst] = React.useState(initial?.shipping?.first_name || '');
const [sLast, setSLast] = React.useState(initial?.shipping?.last_name || '');
const [sAddr1, setSAddr1] = React.useState(initial?.shipping?.address_1 || '');
const [sCity, setSCity] = React.useState(initial?.shipping?.city || '');
const [sPost, setSPost] = React.useState(initial?.shipping?.postcode || '');
const [sCountry, setSCountry] = React.useState(initial?.shipping?.country || bCountry);
const [sState, setSState] = React.useState(initial?.shipping?.state || '');
// If store sells to a single country, force-select it for billing & shipping
React.useEffect(() => {
if (oneCountryOnly) {
const only = countries[0]?.code || '';
if (only && bCountry !== only) setBCountry(only);
}
}, [oneCountryOnly, countries, bCountry]);
React.useEffect(() => {
if (oneCountryOnly) {
const only = countries[0]?.code || '';
if (shipDiff) {
if (only && sCountry !== only) setSCountry(only);
} else {
// keep shipping synced to billing when not different
setSCountry(bCountry);
}
}
}, [oneCountryOnly, countries, shipDiff, bCountry, sCountry]);
// Order meta
const [status, setStatus] = React.useState(initial?.status || 'pending');
const [paymentMethod, setPaymentMethod] = React.useState(initial?.payment_method_id || initial?.payment_method || '');
const [shippingMethod, setShippingMethod] = React.useState(initial?.shipping_method_id || initial?.shipping_method || '');
const [note, setNote] = React.useState(initial?.customer_note || '');
const [registerAsMember, setRegisterAsMember] = React.useState(false);
const [selectedCustomerId, setSelectedCustomerId] = React.useState<number | null>(null);
const [submitting, setSubmitting] = React.useState(false);
const [items, setItems] = React.useState<LineItem[]>(initial?.items || []);
const [coupons, setCoupons] = React.useState('');
const [couponInput, setCouponInput] = React.useState('');
const [validatedCoupons, setValidatedCoupons] = React.useState<any[]>([]);
const [couponValidating, setCouponValidating] = React.useState(false);
// --- Product search for Add Item ---
const [searchQ, setSearchQ] = React.useState('');
const [customerSearchQ, setCustomerSearchQ] = React.useState('');
const productsQ = useQuery({
queryKey: ['products', searchQ],
queryFn: () => ProductsApi.search(searchQ),
enabled: !!searchQ,
});
const customersQ = useQuery({
queryKey: ['customers', customerSearchQ],
queryFn: () => CustomersApi.search(customerSearchQ),
enabled: !!customerSearchQ && customerSearchQ.length >= 2,
});
const raw = productsQ.data as any;
const products: ProductSearchItem[] = Array.isArray(raw)
? raw
: Array.isArray(raw?.data)
? raw.data
: Array.isArray(raw?.rows)
? raw.rows
: [];
const customersRaw = customersQ.data as any;
const customers: any[] = Array.isArray(customersRaw) ? customersRaw : [];
const itemsCount = React.useMemo(
() => items.reduce((n, it) => n + (Number(it.qty) || 0), 0),
[items]
);
const itemsTotal = React.useMemo(
() => items.reduce((sum, it) => sum + (Number(it.qty) || 0) * (Number(it.price) || 0), 0),
[items]
);
// Calculate shipping cost
const shippingCost = React.useMemo(() => {
if (!shippingMethod) return 0;
const method = shippings.find(s => s.id === shippingMethod);
return method ? Number(method.cost) || 0 : 0;
}, [shippingMethod, shippings]);
// Calculate discount from validated coupons
const couponDiscount = React.useMemo(() => {
return validatedCoupons.reduce((sum, c) => sum + (c.discount_amount || 0), 0);
}, [validatedCoupons]);
// Calculate order total (items + shipping - coupons)
const orderTotal = React.useMemo(() => {
return Math.max(0, itemsTotal + shippingCost - couponDiscount);
}, [itemsTotal, shippingCost, couponDiscount]);
// Validate coupon
const validateCoupon = async (code: string) => {
if (!code.trim()) return;
// Check if already added
if (validatedCoupons.some(c => c.code.toLowerCase() === code.toLowerCase())) {
toast.error(__('Coupon already added'));
return;
}
setCouponValidating(true);
try {
const response = await api.post('/coupons/validate', {
code: code.trim(),
subtotal: itemsTotal,
});
if (response.valid) {
setValidatedCoupons([...validatedCoupons, response]);
setCouponInput('');
toast.success(`${__('Coupon applied')}: ${response.code}`);
} else {
toast.error(response.error || __('Invalid coupon'));
}
} catch (error: any) {
toast.error(error?.message || __('Failed to validate coupon'));
} finally {
setCouponValidating(false);
}
};
const removeCoupon = (code: string) => {
setValidatedCoupons(validatedCoupons.filter(c => c.code !== code));
};
// Check if cart has physical products
const hasPhysicalProduct = React.useMemo(
() => items.some(item => {
// Check item's stored metadata first
if (typeof item.virtual !== 'undefined' || typeof item.downloadable !== 'undefined') {
return !item.virtual && !item.downloadable;
}
// Fallback: check products array (for search results)
const product = products.find(p => p.id === item.product_id);
return product ? !product.virtual && !product.downloadable : true; // Default to physical if unknown
}),
[items, products]
);
// --- Currency-aware formatting for unit prices and totals ---
const storeCur = getStoreCurrency();
const currencyCode = currency || initial?.currency || storeCur.currency;
const symbol = initial?.currency_symbol ?? currencySymbol ?? storeCur.symbol;
const money = React.useMemo(() => makeMoneyFormatter({ currency: currencyCode, symbol }), [currencyCode, symbol]);
// Keep shipping country synced to billing when unchecked
React.useEffect(() => {
if (!shipDiff) setSCountry(bCountry);
}, [shipDiff, bCountry]);
// Clamp states when country changes
React.useEffect(() => {
if (bState && !states[bCountry]?.[bState]) setBState('');
}, [bCountry]);
React.useEffect(() => {
if (sState && !states[sCountry]?.[sState]) setSState('');
}, [sCountry]);
const countryOptions = countries.map(c => ({ value: c.code, label: `${c.name} (${c.code})` }));
const bStateOptions = Object.entries(states[bCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
const sStateOptions = Object.entries(states[sCountry] || {}).map(([code, name]) => ({ value: code, label: name }));
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// For virtual-only products, don't send address fields
const billingData: any = {
first_name: bFirst,
last_name: bLast,
email: bEmail,
phone: bPhone,
};
// Only add address fields for physical products
if (hasPhysicalProduct) {
billingData.address_1 = bAddr1;
billingData.city = bCity;
billingData.state = bState;
billingData.postcode = bPost;
billingData.country = bCountry;
}
const payload: OrderPayload = {
status,
billing: billingData,
shipping: shipDiff && hasPhysicalProduct ? {
first_name: sFirst,
last_name: sLast,
address_1: sAddr1,
city: sCity,
state: sState,
postcode: sPost,
country: sCountry,
} : undefined,
payment_method: paymentMethod || undefined,
shipping_method: shippingMethod || undefined,
customer_note: note || undefined,
register_as_member: registerAsMember,
items: itemsEditable ? items : undefined,
coupons: showCoupons ? validatedCoupons.map(c => c.code) : undefined,
};
try {
setSubmitting(true);
await onSubmit(payload);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className={cn('grid grid-cols-1 lg:grid-cols-3 gap-6', className)}>
{/* Left: Order details */}
<div className="lg:col-span-2 space-y-6">
{/* Items and Coupons */}
{(mode === 'create' || showCoupons || itemsEditable) && (
<div className="space-y-4">
{/* Items */}
<div className="rounded border p-4 space-y-3">
<div className="font-medium flex items-center justify-between">
<span>{__('Items')}</span>
{itemsEditable ? (
<div className="flex items-center gap-2">
<SearchableSelect
options={
products.map((p: ProductSearchItem) => ({
value: String(p.id),
label: (
<div className="leading-tight">
<div className="font-medium">{p.name}</div>
{(typeof p.price !== 'undefined' && p.price !== null && !Number.isNaN(Number(p.price))) && (
<div className="text-xs text-muted-foreground">
{p.sale_price ? (
<>
{money(Number(p.sale_price))} <span className="line-through">{money(Number(p.regular_price))}</span>
</>
) : money(Number(p.price))}
</div>
)}
</div>
),
searchText: p.name,
product: p,
}))
}
value={undefined}
onChange={(val: string) => {
const p = products.find((prod: ProductSearchItem) => String(prod.id) === val);
if (!p) return;
if (items.find(x => x.product_id === p.id)) return;
setItems(prev => [
...prev,
{
product_id: p.id,
name: p.name,
price: Number(p.price) || 0,
qty: 1,
virtual: p.virtual,
downloadable: p.downloadable,
}
]);
setSearchQ('');
}}
placeholder={__('Search products…')}
search={searchQ}
onSearch={setSearchQ}
disabled={!itemsEditable}
showCheckIndicator={false}
/>
</div>
) : (
<span className="text-xs opacity-70">({__('locked')})</span>
)}
</div>
{/* Desktop/table view */}
<div className="hidden md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="px-2 py-1">{__('Product')}</th>
<th className="px-2 py-1 w-24">{__('Qty')}</th>
<th className="px-2 py-1 w-16"></th>
</tr>
</thead>
<tbody>
{items.map((it, idx) => (
<tr key={it.product_id} className="border-b last:border-0">
<td className="px-2 py-1">
<div>
<div>{it.name || `Product #${it.product_id}`}</div>
{typeof it.price === 'number' && (
<div className="text-xs opacity-60">
{/* Show strike-through regular price if on sale */}
{(() => {
// Check item's own data first (for edit mode)
if (it.sale_price && it.regular_price && it.sale_price < it.regular_price) {
return (
<>
<span className="line-through text-gray-400 mr-1">{money(Number(it.regular_price))}</span>
<span className="text-red-600 font-semibold">{money(Number(it.sale_price))}</span>
</>
);
}
// Fallback: check products array (for create mode)
const product = products.find(p => p.id === it.product_id);
if (product && product.sale_price && product.regular_price && product.sale_price < product.regular_price) {
return (
<>
<span className="text-red-600 font-semibold">{money(Number(product.sale_price))}</span>
<span className="line-through text-gray-400 ml-1">{money(Number(product.regular_price))}</span>
</>
);
}
return money(Number(it.price));
})()}
</div>
)}
</div>
</td>
<td className="px-2 py-1">
<Input
inputMode="numeric"
pattern="[0-9]*"
min={1}
className="ui-ctrl w-24 text-center"
value={String(it.qty)}
onChange={(e) => {
if (!itemsEditable) return;
const raw = e.target.value.replace(/[^0-9]/g, '');
const v = Math.max(1, parseInt(raw || '1', 10));
setItems(prev => prev.map((x, i) => i === idx ? { ...x, qty: v } : x));
}}
disabled={!itemsEditable}
/>
</td>
<td className="px-2 py-1 text-right">
{itemsEditable && (
<button
className="text-red-600"
type="button"
onClick={() => setItems(prev => prev.filter((x) => x.product_id !== it.product_id))}
>
{__('Remove')}
</button>
)}
</td>
</tr>
))}
{items.length === 0 && (
<tr>
<td className="px-2 py-4 text-center opacity-70" colSpan={3}>{__('No items yet')}</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Mobile/card view */}
<div className="md:hidden divide-y">
{items.length ? (
items.map((it, idx) => (
<div key={it.product_id} className="py-3">
<div className="px-1 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="font-medium truncate">{it.name || `Product #${it.product_id}`}</div>
{typeof it.price === 'number' && (
<div className="text-xs opacity-60">{money(Number(it.price))}</div>
)}
</div>
<div className="text-right">
{itemsEditable && (
<button
className="text-red-600 text-xs"
type="button"
onClick={() => setItems(prev => prev.filter((x) => x.product_id !== it.product_id))}
>
{__('Remove')}
</button>
)}
</div>
</div>
<div className="mt-2 px-1 grid grid-cols-3 gap-2 items-center">
<div className="col-span-2 text-sm opacity-70">{__('Quantity')}</div>
<div>
<Input
inputMode="numeric"
pattern="[0-9]*"
min={1}
className="ui-ctrl w-full text-center"
value={String(it.qty)}
onChange={(e) => {
if (!itemsEditable) return;
const raw = e.target.value.replace(/[^0-9]/g, '');
const v = Math.max(1, parseInt(raw || '1', 10));
setItems(prev => prev.map((x, i) => i === idx ? { ...x, qty: v } : x));
}}
disabled={!itemsEditable}
/>
</div>
</div>
</div>
))
) : (
<div className="px-2 py-4 text-center opacity-70">{__('No items yet')}</div>
)}
</div>
<div className="rounded-md border px-3 py-2 text-sm bg-white/60 space-y-1.5">
<div className="flex justify-between">
<span className="opacity-70">{__('Items')}</span>
<span>{itemsCount}</span>
</div>
<div className="flex justify-between">
<span className="opacity-70">{__('Subtotal')}</span>
<span>
{itemsTotal ? money(itemsTotal) : '—'}
</span>
</div>
{shippingCost > 0 && (
<div className="flex justify-between">
<span className="opacity-70">{__('Shipping')}</span>
<span>{money(shippingCost)}</span>
</div>
)}
{couponDiscount > 0 && (
<div className="flex justify-between text-green-700">
<span>{__('Discount')}</span>
<span>-{money(couponDiscount)}</span>
</div>
)}
<div className="flex justify-between pt-1.5 border-t font-medium">
<span>{__('Total')}</span>
<span>{money(orderTotal)}</span>
</div>
</div>
</div>
{/* Coupons */}
{showCoupons && (
<div className="rounded border p-4 space-y-3">
<div className="font-medium flex items-center justify-between">
<span>{__('Coupons')}</span>
{!itemsEditable && (
<span className="text-xs opacity-70">({__('locked')})</span>
)}
</div>
{/* Coupon Input */}
<div className="flex gap-2">
<Input
value={couponInput}
onChange={(e) => setCouponInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
validateCoupon(couponInput);
}
}}
placeholder={__('Enter coupon code')}
disabled={!itemsEditable || couponValidating}
className="flex-1"
/>
<Button
type="button"
onClick={() => validateCoupon(couponInput)}
disabled={!itemsEditable || !couponInput.trim() || couponValidating}
size="sm"
>
{couponValidating ? __('Validating...') : __('Apply')}
</Button>
</div>
{/* Applied Coupons */}
{validatedCoupons.length > 0 && (
<div className="space-y-2">
{validatedCoupons.map((coupon) => (
<div key={coupon.code} className="flex items-center justify-between p-2 bg-green-50 border border-green-200 rounded text-sm">
<div className="flex-1">
<div className="font-medium text-green-800">{coupon.code}</div>
{coupon.description && (
<div className="text-xs text-green-700 opacity-80">{coupon.description}</div>
)}
<div className="text-xs text-green-700 mt-1">
{coupon.discount_type === 'percent' && `${coupon.amount}% off`}
{coupon.discount_type === 'fixed_cart' && `${money(coupon.amount)} off`}
{coupon.discount_type === 'fixed_product' && `${money(coupon.amount)} off per item`}
{' · '}
<span className="font-medium">{__('Discount')}: {money(coupon.discount_amount)}</span>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeCoupon(coupon.code)}
disabled={!itemsEditable}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{__('Remove')}
</Button>
</div>
))}
</div>
)}
<div className="text-[11px] opacity-70">
{__('Enter coupon code and click Apply to validate and calculate discount')}
</div>
</div>
)}
</div>
)}
{/* Billing address - only show full address for physical products */}
<div className="rounded border p-4 space-y-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{__('Billing address')}</h3>
{mode === 'create' && (
<SearchableSelect
options={customers.map((c: any) => ({
value: String(c.id),
label: (
<div className="leading-tight">
<div className="font-medium">{c.name || c.email}</div>
<div className="text-xs text-muted-foreground">{c.email}</div>
</div>
),
searchText: `${c.name} ${c.email}`,
customer: c,
}))}
value={undefined}
onChange={async (val: string) => {
const customer = customers.find((c: any) => String(c.id) === val);
if (!customer) return;
// Fetch full customer data
try {
const data = await CustomersApi.searchByEmail(customer.email);
if (data.found && data.billing) {
// Always fill name, email, phone
setBFirst(data.billing.first_name || data.first_name || '');
setBLast(data.billing.last_name || data.last_name || '');
setBEmail(data.email || '');
setBPhone(data.billing.phone || '');
// Only fill address fields if cart has physical products
if (hasPhysicalProduct) {
setBAddr1(data.billing.address_1 || '');
setBCity(data.billing.city || '');
setBPost(data.billing.postcode || '');
setBCountry(data.billing.country || bCountry);
setBState(data.billing.state || '');
// Autofill shipping if available
if (data.shipping && data.shipping.address_1) {
setShipDiff(true);
setSFirst(data.shipping.first_name || '');
setSLast(data.shipping.last_name || '');
setSAddr1(data.shipping.address_1 || '');
setSCity(data.shipping.city || '');
setSPost(data.shipping.postcode || '');
setSCountry(data.shipping.country || bCountry);
setSState(data.shipping.state || '');
}
}
// Mark customer as selected (hide register checkbox)
setSelectedCustomerId(data.user_id);
setRegisterAsMember(false);
}
} catch (e) {
console.error('Customer autofill error:', e);
}
setCustomerSearchQ('');
}}
onSearch={setCustomerSearchQ}
placeholder={__('Search customer...')}
className="w-64"
/>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label>{__('First name')}</Label>
<Input className="rounded-md border px-3 py-2" value={bFirst} onChange={e=>setBFirst(e.target.value)} />
</div>
<div>
<Label>{__('Last name')}</Label>
<Input className="rounded-md border px-3 py-2" value={bLast} onChange={e=>setBLast(e.target.value)} />
</div>
<div>
<Label>{__('Email')}</Label>
<Input
inputMode="email"
autoComplete="email"
className="rounded-md border px-3 py-2 appearance-none"
value={bEmail}
onChange={e=>setBEmail(e.target.value)}
/>
</div>
<div>
<Label>{__('Phone')}</Label>
<Input className="rounded-md border px-3 py-2" value={bPhone} onChange={e=>setBPhone(e.target.value)} />
</div>
{/* Only show full address fields for physical products */}
{hasPhysicalProduct && (
<>
<div className="md:col-span-2">
<Label>{__('Address')}</Label>
<Input className="rounded-md border px-3 py-2" value={bAddr1} onChange={e=>setBAddr1(e.target.value)} />
</div>
<div>
<Label>{__('City')}</Label>
<Input className="rounded-md border px-3 py-2" value={bCity} onChange={e=>setBCity(e.target.value)} />
</div>
<div>
<Label>{__('Postcode')}</Label>
<Input className="rounded-md border px-3 py-2" value={bPost} onChange={e=>setBPost(e.target.value)} />
</div>
<div>
<Label>{__('Country')}</Label>
<SearchableSelect
options={countryOptions}
value={bCountry}
onChange={setBCountry}
placeholder={countries.length ? __('Select country') : __('No countries')}
disabled={oneCountryOnly}
/>
</div>
<div>
<Label>{__('State/Province')}</Label>
<Select value={bState} onValueChange={setBState}>
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
<SelectContent className="max-h-64">
{bStateOptions.length ? bStateOptions.map(o => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
)) : (
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
)}
</SelectContent>
</Select>
</div>
</>
)}
</div>
</div>
{/* Conditional: Only show address fields and shipping for physical products */}
{!hasPhysicalProduct && (
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-sm text-blue-800">
{__('Digital products only - shipping not required')}
</div>
)}
{/* Shipping toggle */}
{hasPhysicalProduct && (
<div className="pt-2 mt-4">
<div className="flex items-center gap-2 text-sm">
<Checkbox id="shipDiff" checked={shipDiff} onCheckedChange={(v)=> setShipDiff(Boolean(v))} />
<Label htmlFor="shipDiff" className="leading-none">{__('Ship to a different address')}</Label>
</div>
</div>
)}
{/* Shipping address */}
{hasPhysicalProduct && shipDiff && (
<div className="rounded border p-4 space-y-3 mt-4">
<h3 className="text-sm font-medium">{__('Shipping address')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<Label>{__('First name')}</Label>
<Input className="rounded-md border px-3 py-2" value={sFirst} onChange={e=>setSFirst(e.target.value)} />
</div>
<div>
<Label>{__('Last name')}</Label>
<Input className="rounded-md border px-3 py-2" value={sLast} onChange={e=>setSLast(e.target.value)} />
</div>
<div className="md:col-span-2">
<Label>{__('Address')}</Label>
<Input className="rounded-md border px-3 py-2" value={sAddr1} onChange={e=>setSAddr1(e.target.value)} />
</div>
<div>
<Label>{__('City')}</Label>
<Input className="rounded-md border px-3 py-2" value={sCity} onChange={e=>setSCity(e.target.value)} />
</div>
<div>
<Label>{__('Postcode')}</Label>
<Input className="rounded-md border px-3 py-2" value={sPost} onChange={e=>setSPost(e.target.value)} />
</div>
<div>
<Label>{__('Country')}</Label>
<SearchableSelect
options={countryOptions}
value={sCountry}
onChange={setSCountry}
placeholder={countries.length ? __('Select country') : __('No countries')}
disabled={oneCountryOnly}
/>
</div>
<div>
<Label>{__('State/Province')}</Label>
<Select value={sState} onValueChange={setSState}>
<SelectTrigger className="w-full"><SelectValue placeholder={__('Select state')} /></SelectTrigger>
<SelectContent className="max-h-64">
{sStateOptions.length ? sStateOptions.map(o => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
)) : (
<SelectItem value="__none__" disabled>{__('N/A')}</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
{/* Right: Settings + Actions */}
<aside className="lg:col-span-1">
<div className="sticky top-4 space-y-4">
{rightTop}
<div className="rounded border p-4 space-y-3">
<div className="font-medium">{__('Order Settings')}</div>
<div>
<Label>{__('Status')}</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
{STATUS_LIST.map((s) => (
<SelectItem key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{__('Payment method')}</Label>
<Select value={paymentMethod} onValueChange={setPaymentMethod}>
<SelectTrigger className="w-full"><SelectValue placeholder={payments.length ? __('Select payment') : __('No methods')} /></SelectTrigger>
<SelectContent>
{payments.map(p => {
// If gateway has channels, show channels instead of gateway
if (p.channels && p.channels.length > 0) {
return p.channels.map((channel: any) => (
<SelectItem key={channel.id} value={channel.id}>
{channel.title}
</SelectItem>
));
}
// Otherwise show gateway
return (
<SelectItem key={p.id} value={p.id}>{p.title}</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Only show shipping method for physical products */}
{hasPhysicalProduct && (
<div>
<Label>{__('Shipping method')}</Label>
<Select value={shippingMethod} onValueChange={setShippingMethod}>
<SelectTrigger className="w-full"><SelectValue placeholder={shippings.length ? __('Select shipping') : __('No methods')} /></SelectTrigger>
<SelectContent>
{shippings.map(s => (
<SelectItem key={s.id} value={s.id}>{s.title}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<div className="rounded border p-4 space-y-2">
<Label>{__('Customer note (optional)')}</Label>
<Textarea value={note} onChange={e=>setNote(e.target.value)} placeholder={__('Write a note for this order…')} />
</div>
{/* Register as member checkbox (only for new orders and when no existing customer selected) */}
{mode === 'create' && !selectedCustomerId && (
<div className="rounded border p-4">
<div className="flex items-start gap-2">
<Checkbox
id="register_member"
checked={registerAsMember}
onCheckedChange={(v) => setRegisterAsMember(Boolean(v))}
/>
<div className="flex-1">
<Label htmlFor="register_member" className="cursor-pointer">
{__('Register customer as site member')}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{__('Customer will receive login credentials via email and can track their orders.')}
</p>
</div>
</div>
</div>
)}
<Button type="submit" disabled={submitting} className="w-full">
{submitting ? (mode === 'edit' ? __('Saving…') : __('Creating…')) : (mode === 'edit' ? __('Save changes') : __('Create order'))}
</Button>
</div>
</aside>
</form>
);
}
function isEmptyAddress(a: any) {
if (!a) return true;
const keys = ['first_name','last_name','address_1','city','state','postcode','country'];
return keys.every(k => !a[k]);
}