stripe 1/2

This commit is contained in:
lovebird 2026-02-21 09:30:31 +01:00
parent 65e2a06c6b
commit 4e07182722
14 changed files with 2588 additions and 7970 deletions

View File

@ -18,5 +18,13 @@ export interface EcommerceBundleDependencies {
onNavigate: (path: string) => void;
siteName?: string;
contactEmail?: string;
/** Stripe publishable key — enables Stripe payment option. */
stripePublishableKey?: string;
/** API base URL for Stripe endpoints (e.g. "http://localhost:3333"). */
apiBaseUrl?: string;
/** Stripe return URL for redirect-based payment methods. */
stripeReturnUrl?: string;
/** Currency code for Stripe (default: "eur"). */
currency?: string;
}
export declare const EcommerceBundle: React.FC<EcommerceBundleDependencies>;

View File

@ -21,5 +21,20 @@ export interface CheckoutFlowProps {
success: (msg: string) => void;
error: (msg: string) => void;
};
/**
* Stripe publishable key (pk_test_... or pk_live_...).
* If omitted the Stripe payment option will still render but remain
* in a "loading" state until the key is provided.
*/
stripePublishableKey?: string;
/**
* Base URL for the API server (e.g. "http://localhost:3333").
* Used to call `/api/stripe/create-payment-intent`.
*/
apiBaseUrl?: string;
/** Stripe return URL for redirect-based payment methods. */
stripeReturnUrl?: string;
/** Currency code for Stripe payments (default: "eur"). */
currency?: string;
}
export declare function CheckoutFlow({ userId, userDisplayName, userEmail, onFetchAddresses, onSaveAddress, onPlaceOrder, onBackToCart, onOrderSuccess, toast, }: CheckoutFlowProps): import("react/jsx-runtime").JSX.Element;
export declare function CheckoutFlow({ userId, userDisplayName, userEmail, onFetchAddresses, onSaveAddress, onPlaceOrder, onBackToCart, onOrderSuccess, toast, stripePublishableKey, apiBaseUrl, stripeReturnUrl, currency, }: CheckoutFlowProps): import("react/jsx-runtime").JSX.Element;

View File

@ -1,3 +1,4 @@
import { Stripe as StripeJS } from '@stripe/stripe-js';
/** Shipping address fields collected at checkout. */
export interface ShippingAddress {
fullName: string;
@ -15,7 +16,7 @@ export interface SavedAddress extends ShippingAddress {
phone?: string;
note?: string;
}
export type PaymentMethod = "shopify" | "crypto";
export type PaymentMethod = "shopify" | "crypto" | "stripe";
export interface CheckoutPageProps {
/** Called when user submits the checkout form. */
onPlaceOrder?: (data: {
@ -34,6 +35,14 @@ export interface CheckoutPageProps {
tax?: number;
/** Pre-filled shipping cost. */
shipping?: number;
/** Stripe.js promise (from loadStripe). */
stripePromise?: Promise<StripeJS | null> | null;
/** Stripe PaymentIntent client secret. */
stripeClientSecret?: string | null;
/** Called after Stripe payment succeeds inline (no redirect). */
onStripePaymentSuccess?: () => void;
/** Stripe return URL for redirect-based payment methods. */
stripeReturnUrl?: string;
/** Optional extra class names. */
className?: string;
}
@ -41,4 +50,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, onSaveAddress, onBackToCart, initialShipping, savedAddresses, tax, shipping, className, }: CheckoutPageProps): import("react/jsx-runtime").JSX.Element;
export declare function CheckoutPage({ onPlaceOrder, onSaveAddress, onBackToCart, initialShipping, savedAddresses, tax, shipping, stripePromise, stripeClientSecret, onStripePaymentSuccess, stripeReturnUrl, className, }: CheckoutPageProps): import("react/jsx-runtime").JSX.Element;

View File

@ -0,0 +1,15 @@
export interface StripePaymentFormProps {
/** Called after Stripe confirms payment successfully. */
onPaymentSuccess?: () => void;
/** Return URL after redirect-based payment methods (iDEAL, etc.). */
returnUrl?: string;
/** Pre-fill the email field. */
defaultEmail?: string;
/** Pre-fill the name field in billing details. */
defaultName?: string;
}
/**
* Embedded Stripe payment form using PaymentElement.
* Must be rendered inside a Stripe `<Elements>` provider.
*/
export declare function StripePaymentForm({ onPaymentSuccess, returnUrl, defaultEmail, defaultName, }: StripePaymentFormProps): import("react/jsx-runtime").JSX.Element;

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"dev:lib": "vite -c vite.config.lib.ts",
"build:lib": "vite build -c vite.config.lib.ts",
"build:dev": "vite build --mode development",
"lint": "eslint .",
@ -69,6 +70,8 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.8.0",
"@supabase/supabase-js": "^2.58.0",
"@types/dompurify": "^3.2.0",
"@types/file-saver": "^2.0.7",
@ -96,24 +99,21 @@
"openai": "^6.0.0",
"playwright": "^1.55.1",
"prismjs": "^1.30.0",
"tailwind-merge": "^3.5.0",
"zod": "^3.25.76",
"zod-to-json-schema": "^3.24.6"
},
"peerDependencies": {
"@tanstack/react-query": "^5.83.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"@tanstack/react-query": "^5.83.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"@tanstack/react-query": "^5.83.0",
"zustand": "^5.0.11",
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.83.0",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@ -126,6 +126,9 @@
"globals": "^15.15.0",
"lovable-tagger": "^1.1.10",
"postcss": "^8.5.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
@ -133,6 +136,7 @@
"vite-plugin-dts": "^4.5.4",
"workbox-precaching": "^7.4.0",
"workbox-routing": "^7.4.0",
"workbox-window": "^7.4.0"
"workbox-window": "^7.4.0",
"zustand": "^5.0.11"
}
}
}

View File

@ -112,3 +112,7 @@
- [ ] Analytics tracking (Google Analytics / server-side events).
- [ ] Scheduled feed validation to catch product/price issues.
## 8. Stripe Specifics
https://docs.stripe.com/testing?testing-method=card-numbers
https://dashboard.stripe.com/test/payments

View File

@ -28,6 +28,14 @@ export interface EcommerceBundleDependencies {
// Optional config
siteName?: string;
contactEmail?: string;
/** Stripe publishable key — enables Stripe payment option. */
stripePublishableKey?: string;
/** API base URL for Stripe endpoints (e.g. "http://localhost:3333"). */
apiBaseUrl?: string;
/** Stripe return URL for redirect-based payment methods. */
stripeReturnUrl?: string;
/** Currency code for Stripe (default: "eur"). */
currency?: string;
}
export const EcommerceBundle: React.FC<EcommerceBundleDependencies> = (props) => {
@ -49,6 +57,10 @@ export const EcommerceBundle: React.FC<EcommerceBundleDependencies> = (props) =>
onBackToCart={() => props.onNavigate('/cart')}
onOrderSuccess={() => props.onNavigate('/purchases')}
toast={props.toast}
stripePublishableKey={props.stripePublishableKey}
apiBaseUrl={props.apiBaseUrl}
stripeReturnUrl={props.stripeReturnUrl}
currency={props.currency}
/>
);
}

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect } from "react";
import { CheckoutPage, CheckoutPageProps, SavedAddress } from "./CheckoutPage";
import { useCartStore } from "@/cart/useCartStore";
import { loadStripe, type Stripe as StripeJS } from "@stripe/stripe-js";
export interface CheckoutFlowProps {
/** The authenticated user's ID. */
@ -23,6 +25,21 @@ export interface CheckoutFlowProps {
success: (msg: string) => void;
error: (msg: string) => void;
};
/**
* Stripe publishable key (pk_test_... or pk_live_...).
* If omitted the Stripe payment option will still render but remain
* in a "loading" state until the key is provided.
*/
stripePublishableKey?: string;
/**
* Base URL for the API server (e.g. "http://localhost:3333").
* Used to call `/api/stripe/create-payment-intent`.
*/
apiBaseUrl?: string;
/** Stripe return URL for redirect-based payment methods. */
stripeReturnUrl?: string;
/** Currency code for Stripe payments (default: "eur"). */
currency?: string;
}
export function CheckoutFlow({
@ -35,8 +52,15 @@ export function CheckoutFlow({
onBackToCart,
onOrderSuccess,
toast,
stripePublishableKey,
apiBaseUrl = "",
stripeReturnUrl,
currency = "eur",
}: CheckoutFlowProps) {
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
const [stripePromise, setStripePromise] = useState<Promise<StripeJS | null> | null>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const subtotal = useCartStore((s) => s.subtotal);
// 1. Fetch addresses on mount if user exists
useEffect(() => {
@ -50,7 +74,37 @@ export function CheckoutFlow({
return () => { mounted = false; };
}, [userId, onFetchAddresses]);
// 2. Handle saving a new address
// 2. Load Stripe.js once we have a publishable key
useEffect(() => {
if (!stripePublishableKey) return;
setStripePromise(loadStripe(stripePublishableKey));
}, [stripePublishableKey]);
// 3. Create a PaymentIntent when the flow mounts (so the form is ready)
useEffect(() => {
if (!stripePublishableKey || subtotal <= 0) return;
let mounted = true;
// Stripe expects amount in smallest currency unit (cents for USD)
const amountInCents = Math.round(subtotal * 100);
fetch(`${apiBaseUrl}/api/stripe/create-payment-intent`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: amountInCents, currency }),
})
.then((r) => r.json())
.then((data) => {
if (mounted && data.clientSecret) {
setClientSecret(data.clientSecret);
}
})
.catch((err) => console.error("Failed to create PaymentIntent:", err));
return () => { mounted = false; };
}, [stripePublishableKey, apiBaseUrl, subtotal]);
// 4. Handle saving a new address
const handleSaveAddress = async (address: any) => {
if (!userId) return;
try {
@ -77,7 +131,7 @@ export function CheckoutFlow({
}
};
// 3. Handle order placement
// 5. Handle order placement (for non-Stripe methods)
const handlePlaceOrder = async (data: any) => {
try {
await onPlaceOrder(data);
@ -89,16 +143,26 @@ export function CheckoutFlow({
}
};
const checkoutProps: CheckoutPageProps = {
onPlaceOrder: handlePlaceOrder,
onSaveAddress: handleSaveAddress,
onBackToCart,
savedAddresses,
initialShipping: {
fullName: userDisplayName ?? "",
email: userEmail ?? "",
},
// 6. Handle Stripe payment success (inline, no redirect)
const handleStripePaymentSuccess = () => {
toast?.success("Payment successful!");
onOrderSuccess();
};
return <CheckoutPage {...checkoutProps} />;
return (
<CheckoutPage
onPlaceOrder={handlePlaceOrder}
onSaveAddress={handleSaveAddress}
onBackToCart={onBackToCart}
savedAddresses={savedAddresses}
initialShipping={{
fullName: userDisplayName ?? "",
email: userEmail ?? "",
}}
stripePromise={stripePromise}
stripeClientSecret={clientSecret}
onStripePaymentSuccess={handleStripePaymentSuccess}
stripeReturnUrl={stripeReturnUrl}
/>
);
}

View File

@ -9,6 +9,9 @@ import { cn } from "@/lib/utils";
import { useCartStore } from "@/cart/useCartStore";
import { OrderSummary } from "./OrderSummary";
import { PolicyLinks } from "@/policies/PolicyLinks";
import { StripePaymentForm } from "./StripePaymentForm";
import { Elements } from "@stripe/react-stripe-js";
import type { Stripe as StripeJS } from "@stripe/stripe-js";
/** Shipping address fields collected at checkout. */
export interface ShippingAddress {
@ -29,7 +32,7 @@ export interface SavedAddress extends ShippingAddress {
note?: string;
}
export type PaymentMethod = "shopify" | "crypto";
export type PaymentMethod = "shopify" | "crypto" | "stripe";
export interface CheckoutPageProps {
/** Called when user submits the checkout form. */
@ -49,6 +52,14 @@ export interface CheckoutPageProps {
tax?: number;
/** Pre-filled shipping cost. */
shipping?: number;
/** Stripe.js promise (from loadStripe). */
stripePromise?: Promise<StripeJS | null> | null;
/** Stripe PaymentIntent client secret. */
stripeClientSecret?: string | null;
/** Called after Stripe payment succeeds inline (no redirect). */
onStripePaymentSuccess?: () => void;
/** Stripe return URL for redirect-based payment methods. */
stripeReturnUrl?: string;
/** Optional extra class names. */
className?: string;
}
@ -65,6 +76,10 @@ export function CheckoutPage({
savedAddresses,
tax,
shipping,
stripePromise,
stripeClientSecret,
onStripePaymentSuccess,
stripeReturnUrl,
className,
}: CheckoutPageProps) {
const itemCount = useCartStore((s) => s.itemCount);
@ -134,227 +149,264 @@ export function CheckoutPage({
}
return (
<form
onSubmit={handleSubmit}
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">
{/* Shipping */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Shipping Information</CardTitle>
</CardHeader>
<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={cn("w-full mx-auto max-w-5xl px-4 py-8 overflow-x-hidden", className)}>
<form
onSubmit={handleSubmit}
className="grid gap-8 lg:grid-cols-[1fr_380px]"
>
{/* ---- Left: Shipping + Payment ---- */}
<div className="space-y-6 min-w-0">
{/* Shipping */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Shipping Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Saved Address Selector */}
{savedAddresses && savedAddresses.length > 0 && (
<div className="space-y-2">
{savedAddresses.map((addr) => (
<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
key={addr.id}
type="button"
onClick={() => selectAddress(addr.id)}
onClick={() => selectAddress("custom")}
className={cn(
"flex w-full items-start gap-3 rounded-lg border p-3 text-left transition-colors text-sm",
selectedAddressId === addr.id
"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",
)}
>
<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>
<span className="text-muted-foreground"></span>
<span className="font-medium">Enter a new address</span>
</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>
)}
)}
{/* 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-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="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-city">City</Label>
<Input
id="ck-city"
required
placeholder="New York"
value={form.city}
onChange={(e) => field("city", 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="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"
{/* 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)}
/>
<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 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="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-city">City</Label>
<Input
id="ck-city"
required
placeholder="New York"
value={form.city}
onChange={(e) => field("city", 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="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>
{/* Payment Method */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Payment Method</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<button
type="button"
onClick={() => setPaymentMethod("shopify")}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors",
paymentMethod === "shopify"
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
>
<CreditCard className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Shopify Checkout</p>
<p className="text-xs text-muted-foreground">
Credit / debit card via Shopify
</p>
</div>
</button>
<button
type="button"
onClick={() => setPaymentMethod("crypto")}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors",
paymentMethod === "crypto"
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
>
<Bitcoin className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Crypto Payment</p>
<p className="text-xs text-muted-foreground">
Bitcoin, Ethereum, and more
</p>
</div>
</button>
<button
type="button"
onClick={() => setPaymentMethod("stripe")}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors",
paymentMethod === "stripe"
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
>
<CreditCard className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Stripe</p>
<p className="text-xs text-muted-foreground">
Credit / debit card, Apple&nbsp;Pay, Google&nbsp;Pay
</p>
</div>
</button>
{/* Inline Stripe Payment Form */}
{paymentMethod === "stripe" && stripePromise && stripeClientSecret && (
<div className="pt-2" style={{ minHeight: 350, contain: "content", overflow: "hidden", maxWidth: "100%" }}>
<Elements stripe={stripePromise} options={{ clientSecret: stripeClientSecret }}>
<StripePaymentForm
onPaymentSuccess={onStripePaymentSuccess}
returnUrl={stripeReturnUrl}
defaultEmail={form.email}
defaultName={form.fullName}
/>
</Elements>
</div>
)}
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</div>
{/* Payment Method */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Payment Method</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<button
{/* ---- Right: Summary + CTA ---- */}
<div className="space-y-4">
<OrderSummary tax={tax} shipping={shipping} />
<Separator />
{paymentMethod !== "stripe" && (
<Button type="submit" size="lg" className="w-full">
Place Order
</Button>
)}
{onBackToCart && (
<Button
type="button"
onClick={() => setPaymentMethod("shopify")}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors",
paymentMethod === "shopify"
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
variant="ghost"
className="w-full"
onClick={onBackToCart}
>
<CreditCard className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Shopify Checkout</p>
<p className="text-xs text-muted-foreground">
Credit / debit card via Shopify
</p>
</div>
</button>
Back to Cart
</Button>
)}
<button
type="button"
onClick={() => setPaymentMethod("crypto")}
className={cn(
"flex w-full items-center gap-3 rounded-lg border p-4 text-left transition-colors",
paymentMethod === "crypto"
? "border-primary bg-primary/5"
: "border-border hover:bg-accent/5",
)}
>
<Bitcoin className="h-5 w-5 shrink-0" />
<div>
<p className="font-medium">Crypto Payment</p>
<p className="text-xs text-muted-foreground">
Bitcoin, Ethereum, and more
</p>
</div>
</button>
</CardContent>
</Card>
</div>
{/* ---- Right: Summary + CTA ---- */}
<div className="space-y-4">
<OrderSummary tax={tax} shipping={shipping} />
<Separator />
<Button type="submit" size="lg" className="w-full">
Place Order
</Button>
{onBackToCart && (
<Button
type="button"
variant="ghost"
className="w-full"
onClick={onBackToCart}
>
Back to Cart
</Button>
)}
<PolicyLinks className="pt-4" />
</div>
</form>
<PolicyLinks className="pt-4" />
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,125 @@
import React, { useState } from "react";
import {
PaymentElement,
LinkAuthenticationElement,
AddressElement,
useStripe,
useElements,
} from "@stripe/react-stripe-js";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
export interface StripePaymentFormProps {
/** Called after Stripe confirms payment — receives the PaymentIntent data. */
onPaymentSuccess?: (paymentIntent: { id: string; status: string;[key: string]: any }) => void;
/** Return URL after redirect-based payment methods (iDEAL, etc.). */
returnUrl?: string;
/** Pre-fill the email field. */
defaultEmail?: string;
/** Pre-fill the name field in billing details. */
defaultName?: string;
}
/*
<AddressElement
options={{
mode: "billing",
display: { name: "full" },
defaultValues: {
name: defaultName ?? "",
},
fields: {
phone: "always",
},
}}
/>
*/
/**
* Embedded Stripe payment form using PaymentElement.
* Must be rendered inside a Stripe `<Elements>` provider.
*/
export function StripePaymentForm({
onPaymentSuccess,
returnUrl,
defaultEmail,
defaultName,
}: StripePaymentFormProps) {
const stripe = useStripe();
const elements = useElements();
const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
if (!stripe || !elements) return;
setIsLoading(true);
setMessage(null);
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl ?? `${window.location.origin}/completion`,
},
redirect: "if_required",
});
if (error) {
if (error.type === "card_error" || error.type === "validation_error") {
setMessage(error.message ?? "Payment failed.");
} else {
setMessage("An unexpected error occurred.");
}
} else if (paymentIntent) {
// Payment succeeded without redirect
onPaymentSuccess?.({
id: paymentIntent.id,
status: paymentIntent.status,
});
}
setIsLoading(false);
};
return (
<div className="space-y-4">
<LinkAuthenticationElement
id="link-authentication-element"
options={defaultEmail ? { defaultValues: { email: defaultEmail } } : undefined}
/>
<PaymentElement
id="payment-element"
options={{
layout: "accordion",
defaultValues: {
billingDetails: {
name: defaultName ?? "",
},
},
business: { name: "PolyMech" },
}}
/>
<Button
type="button"
onClick={handleSubmit}
disabled={isLoading || !stripe || !elements}
className="w-full"
size="lg"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing
</>
) : (
"Pay now"
)}
</Button>
{message && (
<p className="text-sm text-destructive text-center">{message}</p>
)}
</div>
);
}

View File

@ -33,7 +33,8 @@ export default defineConfig({
'zustand',
'zustand/vanilla',
'zustand/react',
'zustand/middleware'
'zustand/middleware',
'tailwind-merge'
],
},
outDir: 'dist-lib',