feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform: - apps/storefront: full customer-facing Next.js app with HeroUI (cart, checkout, orders, wishlist, product detail, shop, search, auth) - convex/: serverless backend with schema, queries, mutations, actions, HTTP routes, Stripe/Shippo integrations, and co-located tests - packages/types, packages/utils, packages/convex: shared workspace packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
"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
|
||||
color="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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user