Convex backend (AUDIT-5–10): - schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields + by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes - orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent and applyReturnAccepted internal helpers - returnActions: add acceptReturn action — creates Shippo return label (is_return:true), persists label data, sends return label email to customer - stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard - shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with isReturnTracking flag (return events use return_tracking_update type, skip delivered transition) - emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action Admin UI (AUDIT-1–6): - OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog; add AcceptReturnButton for delivered+returnRequested state - AcceptReturnButton: new action component matching CreateLabelButton pattern - FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound label to "Outbound label" when both are present - statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS - orders detail page and all supporting cards/components Storefront & shared (TS fixes): - checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props - ReviewList: isLoading → isPending for HeroUI v3 Button - packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { Alert, Button, Card, Separator, Spinner } from "@heroui/react";
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import { useShippingRate } from "@/lib/checkout";
|
|
import type {
|
|
CheckoutAddress,
|
|
CheckoutValidationResult,
|
|
CheckoutSelectedShippingRate,
|
|
CheckoutShippingRateResult,
|
|
} from "@/lib/checkout/types";
|
|
import { formatPrice } from "@repo/utils";
|
|
import { CheckoutLineItems } from "../content/CheckoutLineItems";
|
|
|
|
type OrderReviewStepProps = {
|
|
addressId: string;
|
|
addresses: CheckoutAddress[];
|
|
cartResult: CheckoutValidationResult;
|
|
sessionId: string | undefined;
|
|
onProceed: (
|
|
shipmentObjectId: string,
|
|
shippingRate: CheckoutSelectedShippingRate,
|
|
) => void;
|
|
onBack: () => void;
|
|
};
|
|
|
|
function formatShippingAmount(amount: number, currency: string): string {
|
|
return new Intl.NumberFormat("en-GB", {
|
|
style: "currency",
|
|
currency: currency || "GBP",
|
|
minimumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
export function OrderReviewStep({
|
|
addressId,
|
|
addresses,
|
|
cartResult,
|
|
sessionId,
|
|
onProceed,
|
|
onBack,
|
|
}: OrderReviewStepProps) {
|
|
const { result, isLoading, error, retry } = useShippingRate(
|
|
addressId,
|
|
sessionId,
|
|
);
|
|
|
|
const prevSubtotalRef = useRef(cartResult.subtotal);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
result &&
|
|
!isLoading &&
|
|
cartResult.subtotal !== result.cartSubtotal
|
|
) {
|
|
prevSubtotalRef.current = cartResult.subtotal;
|
|
retry();
|
|
}
|
|
}, [cartResult.subtotal, result, isLoading, retry]);
|
|
|
|
const selectedAddress = addresses.find((a) => a.id === addressId);
|
|
const itemCount = cartResult.items.reduce((sum, i) => sum + i.quantity, 0);
|
|
|
|
const canProceed = !!result && !isLoading && !error;
|
|
|
|
const handleContinue = () => {
|
|
if (!result) return;
|
|
onProceed(result.shipmentObjectId, result.selectedRate);
|
|
};
|
|
|
|
const loadingMessageId = "shipping-rate-loading";
|
|
const errorMessageId = "shipping-rate-error";
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr,340px] lg:items-start">
|
|
{/* Left column: shipping rate + address + line items */}
|
|
<div className="flex flex-col gap-6">
|
|
<ShippingRateCard
|
|
result={result}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
onRetry={retry}
|
|
loadingMessageId={loadingMessageId}
|
|
errorMessageId={errorMessageId}
|
|
/>
|
|
|
|
{selectedAddress && (
|
|
<section aria-label="Shipping address">
|
|
<h3 className="mb-2 text-sm font-semibold text-foreground">
|
|
Shipping address
|
|
</h3>
|
|
<AddressPreview address={selectedAddress} />
|
|
</section>
|
|
)}
|
|
|
|
<section aria-label="Order items">
|
|
<h3 className="mb-3 text-sm font-semibold text-foreground">
|
|
Items ({itemCount})
|
|
</h3>
|
|
<CheckoutLineItems
|
|
items={cartResult.items}
|
|
issues={cartResult.issues}
|
|
/>
|
|
</section>
|
|
</div>
|
|
|
|
{/* Right column: sticky order summary */}
|
|
<div className="lg:sticky lg:top-8 lg:self-start">
|
|
<Card className="rounded-lg p-4">
|
|
<Card.Content className="flex flex-col gap-4 p-0">
|
|
<OrderSummary
|
|
cartSubtotal={cartResult.subtotal}
|
|
itemCount={itemCount}
|
|
result={result}
|
|
isLoading={isLoading}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Button
|
|
variant="primary"
|
|
className="w-full bg-[#236f6b] font-medium text-white"
|
|
size="lg"
|
|
onPress={handleContinue}
|
|
isDisabled={!canProceed}
|
|
aria-disabled={!canProceed || undefined}
|
|
aria-describedby={
|
|
isLoading
|
|
? loadingMessageId
|
|
: error
|
|
? errorMessageId
|
|
: undefined
|
|
}
|
|
>
|
|
Continue to payment
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full md:w-auto"
|
|
onPress={onBack}
|
|
>
|
|
Back to address
|
|
</Button>
|
|
</div>
|
|
</Card.Content>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Shipping Rate Card ──────────────────────────────────────────────────────
|
|
|
|
function ShippingRateCard({
|
|
result,
|
|
isLoading,
|
|
error,
|
|
onRetry,
|
|
loadingMessageId,
|
|
errorMessageId,
|
|
}: {
|
|
result: CheckoutShippingRateResult | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
onRetry: () => void;
|
|
loadingMessageId: string;
|
|
errorMessageId: string;
|
|
}) {
|
|
if (isLoading) {
|
|
return (
|
|
<Card className="rounded-lg p-4">
|
|
<Card.Content
|
|
className="flex items-center gap-3 p-0"
|
|
role="status"
|
|
id={loadingMessageId}
|
|
>
|
|
<Spinner size="sm" />
|
|
<p className="text-sm text-default-500">
|
|
Calculating best shipping rate…
|
|
</p>
|
|
</Card.Content>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div id={errorMessageId}>
|
|
<Alert status="danger" role="alert">
|
|
<Alert.Indicator />
|
|
<Alert.Content>
|
|
<Alert.Title>Unable to calculate shipping</Alert.Title>
|
|
<Alert.Description>{error}</Alert.Description>
|
|
</Alert.Content>
|
|
</Alert>
|
|
<Button
|
|
variant="ghost"
|
|
className="mt-3 w-full md:w-auto"
|
|
onPress={onRetry}
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!result) return null;
|
|
|
|
const { selectedRate } = result;
|
|
|
|
return (
|
|
<Card className="rounded-lg p-4">
|
|
<Card.Content className="flex flex-col gap-1 p-0">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<TruckIcon />
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{selectedRate.provider}
|
|
</span>
|
|
</div>
|
|
<span className="text-sm font-semibold text-[#236f6b]">
|
|
{formatShippingAmount(selectedRate.amount, selectedRate.currency)}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-default-500">
|
|
{selectedRate.serviceName}
|
|
{selectedRate.durationTerms && (
|
|
<span> · {selectedRate.durationTerms}</span>
|
|
)}
|
|
</p>
|
|
{selectedRate.estimatedDays > 0 && (
|
|
<p className="text-xs text-default-400">
|
|
Est. {selectedRate.estimatedDays} business{" "}
|
|
{selectedRate.estimatedDays === 1 ? "day" : "days"}
|
|
</p>
|
|
)}
|
|
</Card.Content>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ─── Order Summary ───────────────────────────────────────────────────────────
|
|
|
|
function OrderSummary({
|
|
cartSubtotal,
|
|
itemCount,
|
|
result,
|
|
isLoading,
|
|
}: {
|
|
cartSubtotal: number;
|
|
itemCount: number;
|
|
result: CheckoutShippingRateResult | null;
|
|
isLoading: boolean;
|
|
}) {
|
|
const currency = result?.selectedRate.currency ?? "GBP";
|
|
const shippingAmount = result?.shippingTotal ?? null;
|
|
const orderTotal =
|
|
shippingAmount !== null ? cartSubtotal / 100 + shippingAmount : null;
|
|
|
|
return (
|
|
<section aria-label="Order summary">
|
|
<h2 className="mb-3 font-[family-name:var(--font-fraunces)] text-lg font-semibold">
|
|
Summary
|
|
</h2>
|
|
<dl className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<dt className="text-default-500">Items ({itemCount})</dt>
|
|
<dd className="font-semibold text-foreground">
|
|
{formatPrice(cartSubtotal)}
|
|
</dd>
|
|
</div>
|
|
<div className="flex items-center justify-between text-sm">
|
|
<dt className="text-default-500">Shipping</dt>
|
|
<dd
|
|
className={
|
|
shippingAmount !== null
|
|
? "font-semibold text-foreground"
|
|
: "text-default-400"
|
|
}
|
|
>
|
|
{isLoading
|
|
? "Calculating…"
|
|
: shippingAmount !== null
|
|
? formatShippingAmount(shippingAmount, currency)
|
|
: "—"}
|
|
</dd>
|
|
</div>
|
|
<Separator />
|
|
<div className="flex items-center justify-between">
|
|
<dt className="font-medium text-foreground">Total</dt>
|
|
<dd className="text-lg font-bold text-[#236f6b]">
|
|
{orderTotal !== null
|
|
? formatShippingAmount(orderTotal, currency)
|
|
: "—"}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ─── Address Preview ─────────────────────────────────────────────────────────
|
|
|
|
function AddressPreview({ address }: { address: CheckoutAddress }) {
|
|
return (
|
|
<div className="rounded-md border border-default-200 p-3 text-sm text-default-600">
|
|
<p className="font-medium text-foreground">{address.fullName}</p>
|
|
<p>{address.addressLine1}</p>
|
|
{address.additionalInformation && <p>{address.additionalInformation}</p>}
|
|
<p>{address.city}</p>
|
|
<p>{address.postalCode}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Icons ───────────────────────────────────────────────────────────────────
|
|
|
|
function TruckIcon() {
|
|
return (
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className="text-[#236f6b]"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2" />
|
|
<path d="M15 18H9" />
|
|
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14" />
|
|
<circle cx="17" cy="18" r="2" />
|
|
<circle cx="7" cy="18" r="2" />
|
|
</svg>
|
|
);
|
|
}
|