Files
the-pet-loft/apps/storefront/src/components/checkout/steps/OrderReviewStep.tsx
ianshaloom 3d50cb895c feat(orders): implement QA audit fixes — return flow, refund webhook, TS cleanup
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>
2026-03-07 17:59:29 +03:00

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>
);
}