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!
556 lines
26 KiB
TypeScript
556 lines
26 KiB
TypeScript
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>
|
||
);
|
||
} |