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:
306
apps/storefront/src/components/checkout/content/AddressForm.tsx
Normal file
306
apps/storefront/src/components/checkout/content/AddressForm.tsx
Normal 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 & save address
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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)} × {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)} →{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user