Files
WooNooW/admin-spa/src/routes/Orders/Detail.tsx
dwindown c9e036217e feat: Implement smart back navigation with fallback across all detail/edit pages
Implemented context-aware back button that respects user's navigation path:

Pattern:
```typescript
const handleBack = () => {
  if (window.history.state?.idx > 0) {
    navigate(-1); // Go back in history
  } else {
    navigate('/fallback'); // Safe fallback
  }
};
```

Updated Pages:
 Orders/Detail.tsx → Fallback: /orders
 Orders/Edit.tsx → Fallback: /orders/:id
 Customers/Detail.tsx → Fallback: /customers
 Customers/Edit.tsx → Fallback: /customers
 Products/Edit.tsx → Fallback: /products
 Coupons/Edit.tsx → Fallback: /coupons

User Flow Examples:

1. Normal Navigation (History Available):
   Customers Index → Customer Detail → Orders Tab → Order Detail
   → Click Back → Returns to Customer Detail 

2. Direct Access (No History):
   User opens /orders/360 directly
   → Click Back → Goes to /orders (fallback) 

3. New Tab (No History):
   User opens order in new tab
   → Click Back → Goes to /orders (fallback) 

4. Page Refresh (History Cleared):
   User refreshes page
   → Click Back → Goes to fallback 

Benefits:
 Respects user's navigation path when possible
 Never breaks or leaves the app
 Predictable behavior in all scenarios
 Professional UX (like Gmail, Shopify, etc.)
 Works with deep links and bookmarks

Technical:
- Uses window.history.state.idx to detect history
- Falls back to safe default when no history
- Consistent pattern across all pages
- No URL parameters needed

Result: Back button now works intelligently based on context!
2025-11-21 10:12:26 +07:00

556 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef, useState } from 'react';
import { useParams, useSearchParams, Link, useNavigate } 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-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 { __ } from '@/lib/i18n';
import { usePageHeader } from '@/contexts/PageHeaderContext';
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();
const 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 qc = useQueryClient();
const nav = useNavigate();
const siteTitle = (window as any).wnw?.siteTitle || 'WooNooW';
const { setPageHeader, clearPageHeader } = usePageHeader();
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();
}
// Smart back handler: go back in history if available, otherwise fallback to /orders
const handleBack = () => {
if (window.history.state?.idx > 0) {
nav(-1); // Go back in history
} else {
nav('/orders'); // Fallback to orders index
}
};
// Set contextual header with Back button and Edit action
useEffect(() => {
if (!order || isPrintMode) {
clearPageHeader();
return;
}
const actions = (
<div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={handleBack}>
{__('Back')}
</Button>
<Link to={`/orders/${id}/edit`}>
<Button size="sm">
{__('Edit')}
</Button>
</Link>
</div>
);
setPageHeader(
order.number ? `${__('Order')} #${order.number}` : __('Order'),
actions
);
return () => clearPageHeader();
}, [order, isPrintMode, id, setPageHeader, clearPageHeader, nav]);
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' : ''}`}>
{/* Desktop extra actions - hidden on mobile, shown on desktop */}
<div className="hidden md:flex flex-wrap items-center gap-2">
<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`} 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>
<AlertDialog open={showRetryDialog} onOpenChange={setShowRetryDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{__('Retry Payment')}</AlertDialogTitle>
<AlertDialogDescription>
{__('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>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowRetryDialog(false)}>
{__('Cancel')}
</AlertDialogCancel>
<AlertDialogAction 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')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</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>
);
}