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:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
"use client";
import { useState, useCallback } from "react";
import {
Alert,
Button,
Description,
Input,
InputGroup,
Label,
Spinner,
TextField,
} from "@heroui/react";
import type { AddressFormData } from "@/lib/checkout/types";
const UK_POSTCODE_REGEX = /^[A-Z]{1,2}\d[A-Z\d]?\s?\d[A-Z]{2}$/i;
type AddressFormProps = {
initialData?: AddressFormData;
onSubmit: (data: AddressFormData) => void;
onCancel?: () => void;
isSubmitting: boolean;
validationError?: string | null;
};
type FieldErrors = Partial<Record<keyof AddressFormData, string>>;
export function AddressForm({
initialData,
onSubmit,
onCancel,
isSubmitting,
validationError,
}: AddressFormProps) {
const [firstName, setFirstName] = useState(initialData?.firstName ?? "");
const [lastName, setLastName] = useState(initialData?.lastName ?? "");
const [phone, setPhone] = useState(initialData?.phone ?? "");
const [addressLine1, setAddressLine1] = useState(initialData?.addressLine1 ?? "");
const [additionalInformation, setAdditionalInformation] = useState(
initialData?.additionalInformation ?? "",
);
const [city, setCity] = useState(initialData?.city ?? "");
const [postalCode, setPostalCode] = useState(initialData?.postalCode ?? "");
const [errors, setErrors] = useState<FieldErrors>({});
const validateForm = useCallback((): boolean => {
const next: FieldErrors = {};
if (!firstName.trim()) next.firstName = "First name is required";
if (!lastName.trim()) next.lastName = "Last name is required";
if (!phone.trim()) next.phone = "Phone number is required";
if (!addressLine1.trim()) next.addressLine1 = "Address is required";
if (!city.trim()) next.city = "City / Town is required";
if (!postalCode.trim()) {
next.postalCode = "Postcode is required";
} else if (!UK_POSTCODE_REGEX.test(postalCode.trim())) {
next.postalCode = "Enter a valid UK postcode (e.g. SW1A 2AA)";
}
setErrors(next);
return Object.keys(next).length === 0;
}, [firstName, lastName, phone, addressLine1, city, postalCode]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
onSubmit({
firstName: firstName.trim(),
lastName: lastName.trim(),
phone: phone.trim(),
addressLine1: addressLine1.trim(),
additionalInformation: additionalInformation.trim() || undefined,
city: city.trim(),
postalCode: postalCode.trim().toUpperCase(),
country: "GB",
});
},
[validateForm, onSubmit, firstName, lastName, phone, addressLine1, additionalInformation, city, postalCode],
);
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
{validationError && (
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Validation error</Alert.Title>
<Alert.Description>{validationError}</Alert.Description>
</Alert.Content>
</Alert>
)}
{/* First name + Last name — stacked on mobile, side-by-side on md: */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="addr-firstName">First name</Label>
<Input
id="addr-firstName"
name="firstName"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Jane"
required
autoComplete="given-name"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.firstName || undefined}
aria-describedby={errors.firstName ? "addr-firstName-err" : undefined}
/>
{errors.firstName && (
<p id="addr-firstName-err" className="text-xs text-danger">
{errors.firstName}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="addr-lastName">Last name</Label>
<Input
id="addr-lastName"
name="lastName"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Doe"
required
autoComplete="family-name"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.lastName || undefined}
aria-describedby={errors.lastName ? "addr-lastName-err" : undefined}
/>
{errors.lastName && (
<p id="addr-lastName-err" className="text-xs text-danger">
{errors.lastName}
</p>
)}
</div>
</div>
{/* Phone with +44 prefix */}
<div className="flex flex-col gap-1">
<TextField name="phone" isRequired isDisabled={isSubmitting}>
<Label htmlFor="addr-phone">Phone</Label>
<InputGroup>
<InputGroup.Prefix>+44</InputGroup.Prefix>
<InputGroup.Input
id="addr-phone"
type="tel"
value={phone}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
placeholder="7911 123456"
autoComplete="tel-national"
aria-required="true"
aria-invalid={!!errors.phone || undefined}
aria-describedby={
[errors.phone ? "addr-phone-err" : null, "addr-phone-desc"]
.filter(Boolean)
.join(" ") || undefined
}
/>
</InputGroup>
<Description id="addr-phone-desc">
We will only use this to contact you about your order
</Description>
</TextField>
{errors.phone && (
<p id="addr-phone-err" className="text-xs text-danger">
{errors.phone}
</p>
)}
</div>
{/* Address */}
<div className="flex flex-col gap-1">
<Label htmlFor="addr-line1">Address</Label>
<Input
id="addr-line1"
name="addressLine1"
value={addressLine1}
onChange={(e) => setAddressLine1(e.target.value)}
placeholder="10 Downing Street"
required
autoComplete="address-line1"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.addressLine1 || undefined}
aria-describedby={errors.addressLine1 ? "addr-line1-err" : undefined}
/>
{errors.addressLine1 && (
<p id="addr-line1-err" className="text-xs text-danger">
{errors.addressLine1}
</p>
)}
</div>
{/* Additional information (optional) */}
<div className="flex flex-col gap-1">
<Label htmlFor="addr-additional">
Apartment, suite, floor, unit, etc. <span className="text-default-400">(optional)</span>
</Label>
<Input
id="addr-additional"
name="additionalInformation"
value={additionalInformation}
onChange={(e) => setAdditionalInformation(e.target.value)}
placeholder="Flat 4B"
autoComplete="address-line2"
disabled={isSubmitting}
/>
</div>
{/* City / Town + Postcode — stacked on mobile, side-by-side on md: */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1">
<Label htmlFor="addr-city">City / Town</Label>
<Input
id="addr-city"
name="city"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="London"
required
autoComplete="address-level2"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.city || undefined}
aria-describedby={errors.city ? "addr-city-err" : undefined}
/>
{errors.city && (
<p id="addr-city-err" className="text-xs text-danger">
{errors.city}
</p>
)}
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="addr-postcode">Postcode</Label>
<Input
id="addr-postcode"
name="postalCode"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
placeholder="SW1A 2AA"
required
autoComplete="postal-code"
disabled={isSubmitting}
aria-required="true"
aria-invalid={!!errors.postalCode || undefined}
aria-describedby={errors.postalCode ? "addr-postcode-err" : undefined}
/>
{errors.postalCode && (
<p id="addr-postcode-err" className="text-xs text-danger">
{errors.postalCode}
</p>
)}
</div>
</div>
{/* Country (UK only) */}
<div className="flex flex-col gap-1">
<Label htmlFor="addr-country">Country</Label>
<Input
id="addr-country"
name="country"
value="United Kingdom"
readOnly
disabled
aria-describedby="addr-country-note"
/>
<p id="addr-country-note" className="text-xs text-default-400">
We currently ship to the United Kingdom only.
</p>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 pt-2 md:flex-row md:justify-end">
{onCancel && (
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onCancel}
isDisabled={isSubmitting}
>
Cancel
</Button>
)}
<Button
type="submit"
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
isDisabled={isSubmitting}
isPending={isSubmitting}
>
{({ isPending }) => (
<>
{isPending ? <Spinner color="current" size="sm" /> : null}
Validate &amp; save address
</>
)}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { Button, Chip, RadioGroup, Radio, Label, Skeleton } from "@heroui/react";
import type { CheckoutAddress } from "@/lib/checkout/types";
type AddressSelectorProps = {
addresses: CheckoutAddress[];
selectedId: string | null;
onSelect: (id: string) => void;
onAddNew: () => void;
isLoading: boolean;
};
export function AddressSelector({
addresses,
selectedId,
onSelect,
onAddNew,
isLoading,
}: AddressSelectorProps) {
if (isLoading) {
return <AddressSelectorSkeleton />;
}
if (addresses.length === 0) {
return null;
}
return (
<div className="flex flex-col gap-4">
<RadioGroup
aria-label="Select shipping address"
value={selectedId ?? undefined}
onChange={(value) => onSelect(value)}
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{addresses.map((address) => (
<Radio key={address.id} value={address.id}>
{({ isSelected }) => (
<AddressCard address={address} isSelected={isSelected} />
)}
</Radio>
))}
</div>
</RadioGroup>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onAddNew}
>
+ Add new address
</Button>
</div>
);
}
function AddressCard({
address,
isSelected,
}: {
address: CheckoutAddress;
isSelected: boolean;
}) {
return (
<div
className={`
flex cursor-pointer flex-col gap-2 rounded-lg border-2 p-4 transition-colors
${isSelected ? "border-[#236f6b] bg-[#e8f7f6]/40" : "border-default-200 bg-background hover:border-default-400"}
`}
>
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">{address.fullName}</p>
<div className="flex items-center gap-1.5">
{address.isDefault && (
<Chip size="sm" variant="soft">
Default
</Chip>
)}
{address.isValidated ? (
<Chip size="sm" variant="soft" color="success">
Verified
</Chip>
) : (
<Chip size="sm" variant="soft" color="warning">
Not verified
</Chip>
)}
</div>
</div>
<div className="text-sm text-default-600">
<p>{address.addressLine1}</p>
{address.additionalInformation && <p>{address.additionalInformation}</p>}
<p>{address.city}</p>
<p>{address.postalCode}</p>
</div>
{address.phone && (
<p className="text-xs text-default-400">{address.phone}</p>
)}
</div>
);
}
function AddressSelectorSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex flex-col gap-3 rounded-lg border-2 border-default-200 p-4">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28 rounded-md" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<div className="flex flex-col gap-1.5">
<Skeleton className="h-3 w-40 rounded-md" />
<Skeleton className="h-3 w-32 rounded-md" />
<Skeleton className="h-3 w-20 rounded-md" />
</div>
<Skeleton className="h-3 w-24 rounded-md" />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,250 @@
"use client";
import { Alert, Button, Chip } from "@heroui/react";
import type { CheckoutAddressValidationResult } from "@/lib/checkout/types";
type AddressValidationFeedbackProps = {
result: CheckoutAddressValidationResult;
onAcceptRecommended: () => void;
onKeepOriginal: () => void;
onEditAddress: () => void;
};
const CONFIDENCE_COLOR: Record<string, "success" | "warning" | "danger"> = {
high: "success",
medium: "warning",
low: "danger",
};
const ADDRESS_TYPE_LABELS: Record<string, string> = {
residential: "Residential",
commercial: "Commercial",
po_box: "PO Box",
military: "Military",
unknown: "Unknown",
};
export function AddressValidationFeedback({
result,
onAcceptRecommended,
onKeepOriginal,
onEditAddress,
}: AddressValidationFeedbackProps) {
if (result.isValid && !result.recommendedAddress) {
return <ValidNoCorrections result={result} />;
}
if (result.recommendedAddress) {
return (
<RecommendedCorrections
result={result}
onAcceptRecommended={onAcceptRecommended}
onKeepOriginal={onKeepOriginal}
/>
);
}
return (
<InvalidAddress
result={result}
onEditAddress={onEditAddress}
onKeepOriginal={onKeepOriginal}
/>
);
}
function ValidNoCorrections({ result }: { result: CheckoutAddressValidationResult }) {
return (
<div className="flex flex-col gap-4" role="alert">
<Alert status="success">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>Address verified successfully</Alert.Title>
<Alert.Description>Your address has been validated and is ready to use.</Alert.Description>
</Alert.Content>
</Alert>
<AddressSummaryCard
label="Verified address"
address={result.originalAddress}
addressType={result.addressType}
/>
</div>
);
}
function RecommendedCorrections({
result,
onAcceptRecommended,
onKeepOriginal,
}: {
result: CheckoutAddressValidationResult;
onAcceptRecommended: () => void;
onKeepOriginal: () => void;
}) {
const rec = result.recommendedAddress!;
const confidence = rec.confidenceScore;
const alertStatus = confidence === "high" ? "accent" : "warning";
const changedSet = new Set(result.changedAttributes);
return (
<div className="flex flex-col gap-4">
<Alert status={alertStatus} role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>We found a more accurate version of your address</Alert.Title>
<Alert.Description>{rec.confidenceDescription}</Alert.Description>
</Alert.Content>
</Alert>
<div className="flex items-center gap-2">
<Chip size="sm" variant="soft" color={CONFIDENCE_COLOR[confidence] ?? "default"}>
{confidence.charAt(0).toUpperCase() + confidence.slice(1)} confidence
</Chip>
{result.addressType !== "unknown" && (
<Chip size="sm" variant="soft">
{ADDRESS_TYPE_LABELS[result.addressType] ?? result.addressType}
</Chip>
)}
</div>
{/* Side-by-side on md+, stacked on mobile */}
<div
className="grid grid-cols-1 gap-4 md:grid-cols-2"
aria-label="Address comparison"
>
<AddressSummaryCard
label="You entered"
address={result.originalAddress}
/>
<AddressSummaryCard
label="Suggested"
address={{
addressLine1: rec.addressLine1,
additionalInformation: rec.additionalInformation,
city: rec.city,
postalCode: rec.postalCode,
country: rec.country,
}}
highlightedFields={changedSet}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Button
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={onAcceptRecommended}
>
Use suggested address
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onKeepOriginal}
>
Keep my address
</Button>
</div>
</div>
);
}
function InvalidAddress({
result,
onEditAddress,
onKeepOriginal,
}: {
result: CheckoutAddressValidationResult;
onEditAddress: () => void;
onKeepOriginal: () => void;
}) {
return (
<div className="flex flex-col gap-4">
<Alert status="danger" role="alert">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>We couldn&apos;t verify this address</Alert.Title>
<Alert.Description>Please review the issues below and correct your address.</Alert.Description>
</Alert.Content>
</Alert>
{result.reasons.length > 0 && (
<ul className="list-inside list-disc space-y-1 text-sm text-default-600">
{result.reasons.map((reason, i) => (
<li key={i}>{reason.description}</li>
))}
</ul>
)}
<div className="flex flex-col gap-2 md:flex-row">
<Button
variant="primary"
className="w-full bg-[#236f6b] font-medium text-white md:w-auto"
onPress={onEditAddress}
>
Edit address
</Button>
<Button
variant="ghost"
className="w-full md:w-auto"
onPress={onKeepOriginal}
>
Save anyway
</Button>
</div>
<p className="text-xs text-warning-600">
Unverified addresses may cause delivery failures or surcharges.
</p>
</div>
);
}
function AddressSummaryCard({
label,
address,
addressType,
highlightedFields,
}: {
label: string;
address: {
addressLine1: string;
additionalInformation?: string;
city: string;
postalCode: string;
country: string;
};
addressType?: string;
highlightedFields?: Set<string>;
}) {
const hl = (field: string, text: string) => {
if (highlightedFields?.has(field)) {
return <span className="font-semibold text-[#236f6b]" aria-label={`${field} changed`}>{text}</span>;
}
return text;
};
return (
<div className="rounded-lg border border-default-200 p-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-default-400">
{label}
</p>
<div className="text-sm text-foreground">
<p>{hl("address_line_1", address.addressLine1)}</p>
{address.additionalInformation && (
<p>{hl("address_line_2", address.additionalInformation)}</p>
)}
<p>{hl("city_locality", address.city)}</p>
<p>{hl("postal_code", address.postalCode)}</p>
<p>{hl("country_code", address.country)}</p>
</div>
{addressType && addressType !== "unknown" && (
<div className="mt-2">
<Chip size="sm" variant="soft">
{ADDRESS_TYPE_LABELS[addressType] ?? addressType}
</Chip>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { Chip, Separator } from "@heroui/react";
import Image from "next/image";
import Link from "next/link";
import { formatPrice } from "@repo/utils";
import { getCartItemProductUrl } from "@/lib/cart/constants";
import type {
CheckoutValidatedItem,
CheckoutItemIssue,
} from "@/lib/checkout/types";
const PLACEHOLDER_IMAGE = "/images/placeholder-product.png";
type CheckoutLineItemsProps = {
items: CheckoutValidatedItem[];
issues: CheckoutItemIssue[];
};
function getItemIssues(
variantId: string,
productId: string,
issues: CheckoutItemIssue[],
): CheckoutItemIssue[] {
return issues.filter((issue) => {
if ("variantId" in issue) return issue.variantId === variantId;
if ("productId" in issue) return issue.productId === productId;
return false;
});
}
function isItemUnavailable(itemIssues: CheckoutItemIssue[]): boolean {
return itemIssues.some(
(i) =>
i.type === "out_of_stock" ||
i.type === "variant_inactive" ||
i.type === "variant_not_found" ||
i.type === "product_not_found",
);
}
export function CheckoutLineItems({ items, issues }: CheckoutLineItemsProps) {
if (items.length === 0) return null;
return (
<>
{/* Mobile: stacked card layout */}
<div className="flex flex-col gap-0 md:hidden" role="list" aria-label="Order items">
{items.map((item, i) => (
<div key={item.variantId} role="listitem">
<MobileItemCard
item={item}
itemIssues={getItemIssues(item.variantId, item.productId, issues)}
/>
{i < items.length - 1 && <Separator className="my-3" />}
</div>
))}
</div>
{/* Tablet+: table layout */}
<table className="hidden w-full md:table">
<caption className="sr-only">Order items</caption>
<thead>
<tr className="border-b border-default-200 text-left text-sm text-default-500">
<th scope="col" className="pb-3 font-medium">
Product
</th>
<th scope="col" className="pb-3 font-medium">
Price
</th>
<th scope="col" className="pb-3 text-center font-medium">
Qty
</th>
<th scope="col" className="pb-3 text-right font-medium">
Total
</th>
<th scope="col" className="pb-3 text-right font-medium">
Status
</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<TableRow
key={item.variantId}
item={item}
itemIssues={getItemIssues(
item.variantId,
item.productId,
issues,
)}
/>
))}
</tbody>
</table>
</>
);
}
function MobileItemCard({
item,
itemIssues,
}: {
item: CheckoutValidatedItem;
itemIssues: CheckoutItemIssue[];
}) {
const unavailable = isItemUnavailable(itemIssues);
const productUrl = getCartItemProductUrl(item);
const lineTotal = item.unitPrice * item.quantity;
return (
<div className={`flex items-start gap-3 py-1 ${unavailable ? "opacity-50" : ""}`}>
<Link
href={productUrl}
className="relative block size-16 shrink-0 overflow-hidden rounded-xl bg-default-100"
>
<Image
src={item.imageUrl ?? PLACEHOLDER_IMAGE}
alt=""
width={64}
height={64}
className="object-cover"
unoptimized={!(item.imageUrl ?? "").startsWith("http")}
/>
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="min-w-0">
<Link
href={productUrl}
className="line-clamp-2 text-sm font-medium text-foreground hover:underline"
>
{item.productName}
</Link>
{item.variantName && (
<Chip
size="sm"
variant="soft"
className="mt-1 bg-[#e8f7f6] text-foreground"
>
{item.variantName}
</Chip>
)}
</div>
<IssueBadges issues={itemIssues} />
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm text-default-500">
{formatPrice(item.unitPrice)} &times; {item.quantity}
</span>
<span className="text-sm font-semibold text-[#236f6b]">
{formatPrice(lineTotal)}
</span>
</div>
</div>
</div>
);
}
function TableRow({
item,
itemIssues,
}: {
item: CheckoutValidatedItem;
itemIssues: CheckoutItemIssue[];
}) {
const unavailable = isItemUnavailable(itemIssues);
const productUrl = getCartItemProductUrl(item);
const lineTotal = item.unitPrice * item.quantity;
return (
<tr
className={`border-b border-default-100 ${unavailable ? "opacity-50" : ""}`}
>
{/* Product */}
<td className="py-4 pr-4">
<div className="flex items-center gap-3">
<Link
href={productUrl}
className="relative block size-14 shrink-0 overflow-hidden rounded-xl bg-default-100"
>
<Image
src={item.imageUrl ?? PLACEHOLDER_IMAGE}
alt=""
width={56}
height={56}
className="object-cover"
unoptimized={!(item.imageUrl ?? "").startsWith("http")}
/>
</Link>
<div className="min-w-0">
<Link
href={productUrl}
className="line-clamp-2 text-sm font-medium text-foreground hover:underline"
>
{item.productName}
</Link>
{item.variantName && (
<Chip
size="sm"
variant="soft"
className="mt-1 bg-[#e8f7f6] text-foreground"
>
{item.variantName}
</Chip>
)}
</div>
</div>
</td>
{/* Price */}
<td className="py-4 text-sm text-foreground">
{formatPrice(item.unitPrice)}
</td>
{/* Qty */}
<td className="py-4 text-center text-sm text-foreground">
{item.quantity}
</td>
{/* Line total */}
<td className="py-4 text-right text-sm font-semibold text-[#236f6b]">
{formatPrice(lineTotal)}
</td>
{/* Status */}
<td className="py-4 text-right">
<IssueBadges issues={itemIssues} />
</td>
</tr>
);
}
function IssueBadges({ issues }: { issues: CheckoutItemIssue[] }) {
if (issues.length === 0) return null;
return (
<div className="flex flex-wrap gap-1.5">
{issues.map((issue, i) => (
<IssueBadge key={`${issue.type}-${i}`} issue={issue} />
))}
</div>
);
}
function IssueBadge({ issue }: { issue: CheckoutItemIssue }) {
switch (issue.type) {
case "out_of_stock":
return (
<Chip size="sm" variant="soft" className="bg-danger-50 text-danger">
Out of stock
</Chip>
);
case "insufficient_stock":
return (
<Chip size="sm" variant="soft" className="bg-warning-50 text-warning-600">
Only {issue.available} left
</Chip>
);
case "price_changed":
return (
<Chip size="sm" variant="soft" className="bg-primary-50 text-primary">
Price updated: {formatPrice(issue.oldPrice)} &rarr;{" "}
{formatPrice(issue.newPrice)}
</Chip>
);
case "variant_inactive":
case "variant_not_found":
case "product_not_found":
return (
<Chip size="sm" variant="soft" className="bg-danger-50 text-danger">
Unavailable
</Chip>
);
}
}

View File

@@ -0,0 +1,43 @@
"use client";
import { Separator } from "@heroui/react";
import { formatPrice } from "@repo/utils";
type CheckoutOrderSummaryProps = {
subtotal: number;
itemCount: number;
};
export function CheckoutOrderSummary({
subtotal,
itemCount,
}: CheckoutOrderSummaryProps) {
return (
<section aria-label="Order summary">
<h2 className="mb-3 text-lg font-semibold font-[family-name:var(--font-fraunces)]">
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(subtotal)}
</dd>
</div>
<div className="flex items-center justify-between text-sm">
<dt className="text-default-500">Shipping</dt>
<dd className="text-default-400">Calculated at next step</dd>
</div>
<Separator />
<div className="flex items-center justify-between">
<dt className="font-medium text-foreground">Subtotal</dt>
<dd className="text-lg font-bold text-[#236f6b]">
{formatPrice(subtotal)}
</dd>
</div>
</dl>
</section>
);
}