Business store shipping groups/static rates UI, and customer checkout that quotes and applies shipping on delivery and payment. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { X } from 'lucide-react'
|
|
import { useT } from '../i18n/useT'
|
|
import type { Order } from '../services/orderService'
|
|
import { formatCellForDisplay } from '../lib/cellNumber'
|
|
import { formatIrtPrice } from '../utils/irtPrice'
|
|
import modalStyles from './VariationsModal.module.css'
|
|
import styles from './OrderItemsModal.module.css'
|
|
|
|
interface OrderItemsModalProps {
|
|
open: boolean
|
|
order: Order | null
|
|
onClose: () => void
|
|
}
|
|
|
|
const ANIMATION_MS = 220
|
|
|
|
function displayName(order: Order) {
|
|
const name = [order.customer.firstName, order.customer.lastName].filter(Boolean).join(' ').trim()
|
|
return name || '—'
|
|
}
|
|
|
|
function formatVariantLabel(selections: Order['items'][number]['selections']) {
|
|
if (!selections.length) return '—'
|
|
return selections.map((s) => `${s.variationName}: ${s.value}`).join(' · ')
|
|
}
|
|
|
|
function totalQuantity(order: Order) {
|
|
return order.items.reduce((sum, item) => sum + item.quantity, 0)
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: null
|
|
}
|
|
|
|
function parseOrderShipping(shippingAddress: Record<string, unknown>) {
|
|
const root = asRecord(shippingAddress) ?? {}
|
|
const deliveryMode =
|
|
root.deliveryMode === 'pickup' || root.deliveryMode === 'delivery'
|
|
? root.deliveryMode
|
|
: root.province === 'Pickup'
|
|
? 'pickup'
|
|
: 'delivery'
|
|
const shipping = asRecord(root.shipping)
|
|
const groupsRaw = Array.isArray(shipping?.groups) ? shipping.groups : []
|
|
const groups = groupsRaw
|
|
.map((entry) => {
|
|
const row = asRecord(entry)
|
|
if (!row) return null
|
|
const groupId = typeof row.groupId === 'string' ? row.groupId : ''
|
|
if (!groupId) return null
|
|
return {
|
|
groupId,
|
|
title: typeof row.title === 'string' ? row.title : '',
|
|
methodLabel: typeof row.methodLabel === 'string' ? row.methodLabel : null,
|
|
amount: typeof row.amount === 'number' && Number.isFinite(row.amount) ? row.amount : 0,
|
|
itemNames: Array.isArray(row.itemNames)
|
|
? row.itemNames.filter((name): name is string => typeof name === 'string')
|
|
: [],
|
|
}
|
|
})
|
|
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
|
|
|
const addressParts = [
|
|
typeof root.province === 'string' ? root.province : '',
|
|
typeof root.city === 'string' ? root.city : '',
|
|
typeof root.address === 'string' ? root.address : '',
|
|
typeof root.postalCode === 'string' ? root.postalCode : '',
|
|
].filter((part) => part && part !== 'Pickup' && part !== 'Store' && part !== 'In-store pickup')
|
|
|
|
return {
|
|
deliveryMode,
|
|
addressLine: addressParts.join(' · '),
|
|
groups,
|
|
}
|
|
}
|
|
|
|
export function OrderItemsModal({ open, order, onClose }: OrderItemsModalProps) {
|
|
const t = useT()
|
|
const [mounted, setMounted] = useState(open)
|
|
const [closing, setClosing] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setMounted(true)
|
|
setClosing(false)
|
|
} else if (mounted) {
|
|
setClosing(true)
|
|
const timer = setTimeout(() => {
|
|
setMounted(false)
|
|
setClosing(false)
|
|
}, ANIMATION_MS)
|
|
return () => clearTimeout(timer)
|
|
}
|
|
}, [open, mounted])
|
|
|
|
useEffect(() => {
|
|
if (!mounted || closing) return
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
}
|
|
document.addEventListener('keydown', onKey)
|
|
return () => document.removeEventListener('keydown', onKey)
|
|
}, [mounted, closing, onClose])
|
|
|
|
if (!mounted || !order) return null
|
|
|
|
const itemCount = totalQuantity(order)
|
|
|
|
return createPortal(
|
|
<div
|
|
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className={`${modalStyles.modal} ${styles.modalWide} ${closing ? modalStyles.modalOut : modalStyles.modalIn}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="order-items-title"
|
|
>
|
|
<div className={modalStyles.header}>
|
|
<div>
|
|
<h2 id="order-items-title" className={modalStyles.title}>
|
|
{t('orderItems.title')}
|
|
</h2>
|
|
<p className={modalStyles.subtitle}>{order.orderNumber}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={modalStyles.closeBtn}
|
|
onClick={onClose}
|
|
aria-label={t('orderItems.close')}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`${modalStyles.body} ${styles.body}`}>
|
|
<div className={styles.metaRow}>
|
|
<span className={styles.metaItem}>
|
|
{t('orderItems.customer')}: <strong>{displayName(order)}</strong>
|
|
</span>
|
|
<span className={styles.metaItem}>
|
|
{t('orderItems.phone')}:{' '}
|
|
<strong>{formatCellForDisplay(order.customer.cellNumber)}</strong>
|
|
</span>
|
|
<span className={styles.metaItem}>
|
|
{t('orderItems.items')}: <strong>{itemCount}</strong>
|
|
</span>
|
|
</div>
|
|
|
|
{(() => {
|
|
const shippingInfo = parseOrderShipping(order.shippingAddress)
|
|
return (
|
|
<div className={styles.shippingBlock}>
|
|
<div className={styles.shippingSectionTitle}>{t('orderItems.delivery')}</div>
|
|
{shippingInfo.deliveryMode === 'pickup' ? (
|
|
<p className={styles.addressText}>{t('orderItems.pickup')}</p>
|
|
) : shippingInfo.addressLine ? (
|
|
<p className={styles.addressText}>{shippingInfo.addressLine}</p>
|
|
) : null}
|
|
|
|
{shippingInfo.groups.length > 0 ? (
|
|
<ul className={styles.shippingGroupList}>
|
|
{shippingInfo.groups.map((group) => (
|
|
<li key={group.groupId} className={styles.shippingGroupCard}>
|
|
<div className={styles.shippingGroupHeader}>
|
|
<span className={styles.shippingGroupTitle}>{group.title}</span>
|
|
<span className={styles.shippingGroupAmount}>
|
|
{group.amount > 0
|
|
? formatIrtPrice(group.amount)
|
|
: t('orderItems.freeShipping')}
|
|
</span>
|
|
</div>
|
|
{group.methodLabel ? (
|
|
<div className={styles.shippingMethod}>{group.methodLabel}</div>
|
|
) : null}
|
|
{group.itemNames.length > 0 ? (
|
|
<ul className={styles.shippingItemNames}>
|
|
{group.itemNames.map((name) => (
|
|
<li key={`${group.groupId}-${name}`}>{name}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{order.items.length === 0 ? (
|
|
<p className={styles.empty}>{t('orderItems.empty')}</p>
|
|
) : (
|
|
<div className={styles.tableBlock}>
|
|
<div className={styles.tableHead}>
|
|
<span aria-hidden="true" />
|
|
<span>{t('orderItems.product')}</span>
|
|
<span>{t('orderItems.qty')}</span>
|
|
<span>{t('orderItems.unitPrice')}</span>
|
|
<span>{t('orderItems.lineTotal')}</span>
|
|
</div>
|
|
<ul className={styles.itemList}>
|
|
{order.items.map((item) => (
|
|
<li key={item.id} className={styles.itemRow}>
|
|
<div className={styles.itemThumb}>
|
|
{item.productImage ? (
|
|
<img src={item.productImage} alt="" />
|
|
) : (
|
|
<div className={styles.itemThumbPlaceholder} />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className={styles.itemTitle}>{item.productTitle}</div>
|
|
<div className={styles.itemVariant}>{formatVariantLabel(item.selections)}</div>
|
|
{item.variantSku && (
|
|
<div className={styles.itemSku}>
|
|
{t('orderItems.sku', { sku: item.variantSku })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={styles.qtyCell}>{item.quantity}</div>
|
|
<div className={styles.unitCell}>{formatIrtPrice(item.unitPrice)}</div>
|
|
<div className={styles.totalCell}>{formatIrtPrice(item.lineTotal)}</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.totalsBlock}>
|
|
<div className={styles.totalsRow}>
|
|
<span>{t('orderItems.subtotal')}</span>
|
|
<span className={styles.totalsValue}>{formatIrtPrice(order.subtotal)}</span>
|
|
</div>
|
|
<div className={styles.totalsRow}>
|
|
<span>{t('orderItems.shipping')}</span>
|
|
<span className={styles.totalsValue}>
|
|
{order.shippingTotal > 0
|
|
? formatIrtPrice(order.shippingTotal)
|
|
: t('orderItems.freeShipping')}
|
|
</span>
|
|
</div>
|
|
{order.discountTotal > 0 ? (
|
|
<div className={styles.totalsRow}>
|
|
<span>{t('orderItems.discount')}</span>
|
|
<span className={styles.totalsValue}>{formatIrtPrice(order.discountTotal)}</span>
|
|
</div>
|
|
) : null}
|
|
<div className={`${styles.totalsRow} ${styles.totalsRowGrand}`}>
|
|
<span>
|
|
{t('orderItems.summary', {
|
|
count: itemCount,
|
|
itemsLabel:
|
|
itemCount === 1 ? t('orderItems.item') : t('orderItems.itemsPlural'),
|
|
})}
|
|
</span>
|
|
<span className={styles.summaryValue}>{formatIrtPrice(order.total)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={modalStyles.actions}>
|
|
<button type="button" className={modalStyles.cancelBtn} onClick={onClose}>
|
|
{t('orderItems.close')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|