ecommerce 2/3

This commit is contained in:
lovebird 2026-02-18 16:14:42 +01:00
parent 1b34cb5922
commit 159e6e7147
8 changed files with 1380 additions and 1186 deletions

View File

@ -7,6 +7,14 @@ export interface ShippingAddress {
zip: string;
country: string;
}
/** A saved address with extra metadata (matches pm-pics SavedShippingAddress). */
export interface SavedAddress extends ShippingAddress {
id: string;
label?: string;
isDefault?: boolean;
phone?: string;
note?: string;
}
export type PaymentMethod = "shopify" | "crypto";
export interface CheckoutPageProps {
/** Called when user submits the checkout form. */
@ -18,6 +26,8 @@ export interface CheckoutPageProps {
onBackToCart?: () => void;
/** Pre-fill shipping form fields (e.g. from user profile). */
initialShipping?: Partial<ShippingAddress>;
/** Saved shipping addresses — enables address selector. */
savedAddresses?: SavedAddress[];
/** Pre-filled tax amount, if known. */
tax?: number;
/** Pre-filled shipping cost. */
@ -29,4 +39,4 @@ export interface CheckoutPageProps {
* Checkout page two-column layout with shipping form + payment selector on
* the left and an OrderSummary on the right.
*/
export declare function CheckoutPage({ onPlaceOrder, onBackToCart, initialShipping, tax, shipping, className, }: CheckoutPageProps): import("react/jsx-runtime").JSX.Element;
export declare function CheckoutPage({ onPlaceOrder, onBackToCart, initialShipping, savedAddresses, tax, shipping, className, }: CheckoutPageProps): import("react/jsx-runtime").JSX.Element;

View File

@ -7,7 +7,7 @@ export type { CartItem, CartActions, CartState } from './cart/types';
export { OrderSummary } from './checkout/OrderSummary';
export type { OrderSummaryProps } from './checkout/OrderSummary';
export { CheckoutPage } from './checkout/CheckoutPage';
export type { CheckoutPageProps, ShippingAddress, PaymentMethod, } from './checkout/CheckoutPage';
export type { CheckoutPageProps, ShippingAddress, SavedAddress, PaymentMethod, } from './checkout/CheckoutPage';
export { PolicyPage } from './policies/PolicyPage';
export type { PolicyPageProps } from './policies/PolicyPage';
export { ShippingPage } from './policies/ShippingPage';

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -44,7 +44,7 @@ export function CartPage({ onCheckout, className }: CartPageProps) {
/* ---------- Cart with items ---------- */
return (
<div className={cn("mx-auto max-w-3xl space-y-6", className)}>
<div className={cn("mx-auto max-w-3xl space-y-6 px-4 py-8", className)}>
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2 text-xl">

View File

@ -1,5 +1,5 @@
import React, { useState } from "react";
import { CreditCard, Bitcoin, ShoppingBag } from "lucide-react";
import { CreditCard, Bitcoin, ShoppingBag, MapPin, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@ -20,6 +20,15 @@ export interface ShippingAddress {
country: string;
}
/** A saved address with extra metadata (matches pm-pics SavedShippingAddress). */
export interface SavedAddress extends ShippingAddress {
id: string;
label?: string;
isDefault?: boolean;
phone?: string;
note?: string;
}
export type PaymentMethod = "shopify" | "crypto";
export interface CheckoutPageProps {
@ -28,10 +37,14 @@ export interface CheckoutPageProps {
shipping: ShippingAddress;
paymentMethod: PaymentMethod;
}) => void;
/** Called when user wants to save the entered address to their profile. */
onSaveAddress?: (address: ShippingAddress) => void;
/** Called when user clicks "Back to Cart". */
onBackToCart?: () => void;
/** Pre-fill shipping form fields (e.g. from user profile). */
initialShipping?: Partial<ShippingAddress>;
/** Saved shipping addresses — enables address selector. */
savedAddresses?: SavedAddress[];
/** Pre-filled tax amount, if known. */
tax?: number;
/** Pre-filled shipping cost. */
@ -46,8 +59,10 @@ export interface CheckoutPageProps {
*/
export function CheckoutPage({
onPlaceOrder,
onSaveAddress,
onBackToCart,
initialShipping,
savedAddresses,
tax,
shipping,
className,
@ -55,20 +70,51 @@ export function CheckoutPage({
const itemCount = useCartStore((s) => s.itemCount);
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("shopify");
const [form, setForm] = useState<ShippingAddress>({
fullName: initialShipping?.fullName ?? "",
email: initialShipping?.email ?? "",
address: initialShipping?.address ?? "",
city: initialShipping?.city ?? "",
zip: initialShipping?.zip ?? "",
country: initialShipping?.country ?? "",
const [saveAddress, setSaveAddress] = useState(false);
// Find default address or first address from savedAddresses
const defaultAddress = savedAddresses?.find((a) => a.isDefault) ?? savedAddresses?.[0];
// Determine initial form values: saved default > initialShipping > empty
const resolveInitial = (): ShippingAddress => ({
fullName: defaultAddress?.fullName ?? initialShipping?.fullName ?? "",
email: defaultAddress?.email ?? initialShipping?.email ?? "",
address: defaultAddress?.address ?? initialShipping?.address ?? "",
city: defaultAddress?.city ?? initialShipping?.city ?? "",
zip: defaultAddress?.zip ?? initialShipping?.zip ?? "",
country: defaultAddress?.country ?? initialShipping?.country ?? "",
});
const [form, setForm] = useState<ShippingAddress>(resolveInitial);
const [selectedAddressId, setSelectedAddressId] = useState<string>(
defaultAddress?.id ?? "custom"
);
// Sync form when a saved address is selected
const selectAddress = (id: string) => {
setSelectedAddressId(id);
if (id === "custom") return;
const addr = savedAddresses?.find((a) => a.id === id);
if (addr) {
setForm({
fullName: addr.fullName,
email: addr.email,
address: addr.address,
city: addr.city,
zip: addr.zip,
country: addr.country,
});
}
};
const field = (key: keyof ShippingAddress, value: string) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (saveAddress && selectedAddressId === "custom" && onSaveAddress) {
onSaveAddress(form);
}
onPlaceOrder?.({ shipping: form, paymentMethod });
};
@ -90,7 +136,7 @@ export function CheckoutPage({
return (
<form
onSubmit={handleSubmit}
className={cn("mx-auto grid max-w-5xl gap-8 lg:grid-cols-[1fr_380px]", className)}
className={cn("mx-auto grid max-w-5xl gap-8 px-4 py-8 lg:grid-cols-[1fr_380px]", className)}
>
{/* ---- Left: Shipping + Payment ---- */}
<div className="space-y-6">
@ -99,72 +145,142 @@ export function CheckoutPage({
<CardHeader>
<CardTitle className="text-lg">Shipping Information</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-name">Full Name</Label>
<Input
id="ck-name"
required
placeholder="Jane Doe"
value={form.fullName}
onChange={(e) => field("fullName", e.target.value)}
/>
</div>
<CardContent className="space-y-4">
{/* Saved Address Selector */}
{savedAddresses && savedAddresses.length > 0 && (
<div className="space-y-2">
<Label className="text-sm font-medium flex items-center gap-1.5">
<MapPin className="h-4 w-4" />
Saved Addresses
</Label>
<div className="space-y-2">
{savedAddresses.map((addr) => (
<button
key={addr.id}
type="button"
onClick={() => selectAddress(addr.id)}
className={cn(
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors text-sm",
selectedAddressId === addr.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
>
<MapPin className="h-4 w-4 mt-0.5 shrink-0" />
<div className="min-w-0">
<p className="font-medium">
{addr.label || addr.fullName}
{addr.isDefault && (
<span className="ml-2 text-xs text-muted-foreground">(Default)</span>
)}
</p>
<p className="text-muted-foreground truncate">
{addr.address}, {addr.city}, {addr.country}
</p>
</div>
</button>
))}
<button
type="button"
onClick={() => selectAddress("custom")}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors text-sm",
selectedAddressId === "custom"
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
>
<span className="text-muted-foreground"></span>
<span className="font-medium">Enter a new address</span>
</button>
</div>
</div>
)}
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-email">Email</Label>
<Input
id="ck-email"
type="email"
required
placeholder="jane@example.com"
value={form.email}
onChange={(e) => field("email", e.target.value)}
/>
</div>
{/* Form Fields */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-name">Full Name</Label>
<Input
id="ck-name"
required
placeholder="Jane Doe"
value={form.fullName}
onChange={(e) => field("fullName", e.target.value)}
/>
</div>
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-address">Address</Label>
<Input
id="ck-address"
required
placeholder="123 Main St"
value={form.address}
onChange={(e) => field("address", e.target.value)}
/>
</div>
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-email">Email</Label>
<Input
id="ck-email"
type="email"
required
placeholder="jane@example.com"
value={form.email}
onChange={(e) => field("email", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ck-city">City</Label>
<Input
id="ck-city"
required
placeholder="New York"
value={form.city}
onChange={(e) => field("city", e.target.value)}
/>
</div>
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-address">Address</Label>
<Input
id="ck-address"
required
placeholder="123 Main St"
value={form.address}
onChange={(e) => field("address", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ck-zip">ZIP / Postal Code</Label>
<Input
id="ck-zip"
required
placeholder="10001"
value={form.zip}
onChange={(e) => field("zip", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ck-city">City</Label>
<Input
id="ck-city"
required
placeholder="New York"
value={form.city}
onChange={(e) => field("city", e.target.value)}
/>
</div>
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-country">Country</Label>
<Input
id="ck-country"
required
placeholder="United States"
value={form.country}
onChange={(e) => field("country", e.target.value)}
/>
<div className="space-y-1.5">
<Label htmlFor="ck-zip">ZIP / Postal Code</Label>
<Input
id="ck-zip"
required
placeholder="10001"
value={form.zip}
onChange={(e) => field("zip", e.target.value)}
/>
</div>
<div className="sm:col-span-2 space-y-1.5">
<Label htmlFor="ck-country">Country</Label>
<Input
id="ck-country"
required
placeholder="United States"
value={form.country}
onChange={(e) => field("country", e.target.value)}
/>
</div>
{selectedAddressId === "custom" && onSaveAddress && (
<div className="sm:col-span-2 flex items-center gap-2 pt-1">
<input
type="checkbox"
id="ck-save-address"
checked={saveAddress}
onChange={(e) => setSaveAddress(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary"
/>
<Label htmlFor="ck-save-address" className="text-sm font-normal cursor-pointer flex items-center gap-1.5">
<Save className="h-3.5 w-3.5" />
Save this address for future orders
</Label>
</div>
)}
</div>
</CardContent>
</Card>

View File

@ -17,6 +17,7 @@ export { CheckoutPage } from "./checkout/CheckoutPage";
export type {
CheckoutPageProps,
ShippingAddress,
SavedAddress,
PaymentMethod,
} from "./checkout/CheckoutPage";

View File

@ -23,10 +23,10 @@ export interface PolicyLinksProps {
*/
export function PolicyLinks({ links = defaultLinks, className }: PolicyLinksProps) {
return (
<nav className={cn("flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground", className)}>
<nav className={cn("flex flex-wrap items-center justify-center gap-y-2 text-xs text-muted-foreground", className)}>
{links.map((l, i) => (
<React.Fragment key={l.href}>
{i > 0 && <span className="hidden sm:inline" aria-hidden>·</span>}
{i > 0 && <span className="mx-2 select-none" aria-hidden>·</span>}
<a href={l.href} className="hover:text-foreground hover:underline transition-colors">
{l.label}
</a>