stripe 1/2
This commit is contained in:
parent
65e2a06c6b
commit
4e07182722
@ -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>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
15
packages/ecommerce/dist-lib/checkout/StripePaymentForm.d.ts
vendored
Normal file
15
packages/ecommerce/dist-lib/checkout/StripePaymentForm.d.ts
vendored
Normal 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
5078
packages/ecommerce/package-lock.json
generated
5078
packages/ecommerce/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 Pay, Google 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>
|
||||
);
|
||||
}
|
||||
|
||||
125
packages/ecommerce/src/checkout/StripePaymentForm.tsx
Normal file
125
packages/ecommerce/src/checkout/StripePaymentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -33,7 +33,8 @@ export default defineConfig({
|
||||
'zustand',
|
||||
'zustand/vanilla',
|
||||
'zustand/react',
|
||||
'zustand/middleware'
|
||||
'zustand/middleware',
|
||||
'tailwind-merge'
|
||||
],
|
||||
},
|
||||
outDir: 'dist-lib',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user