Build a custom checkout
By the end of this page
Your own checkout UI and your own payment provider, with PlugStore still handling the cart, totals, analytics and order persistence.
When you need this
The built-in checkout covers WhatsApp and Pix. Reach for a custom one when you need a real card acquirer, extra fields (CPF, delivery window, gift message), or a multi-step flow of your own design.
The form
ShippingInfo is the shape processCheckout expects:
src/MyCheckout.tsx
import { useState } from 'react';
import { useCart, useCheckout, useMoney } from '@neverleans-labs/plug-store-core';
import type { ShippingInfo } from '@neverleans-labs/plug-store-core';
const EMPTY: ShippingInfo = {
firstName: '',
lastName: '',
email: '',
address: '',
city: '',
state: '',
zip: '',
country: 'BR',
};
export function MyCheckout() {
const { items, subtotal, discount, shippingCost, total } = useCart();
const { processCheckout, loading, error } = useCheckout({ autoRedirect: true });
const money = useMoney();
const [shipping, setShipping] = useState<ShippingInfo>(EMPTY);
const set = (key: keyof ShippingInfo) => (event: React.ChangeEvent<HTMLInputElement>) =>
setShipping((prev) => ({ ...prev, [key]: event.target.value }));
const submit = async (event: React.FormEvent) => {
event.preventDefault();
// 'stripe' is the redirect-style slot; the adapter below overrides it.
const result = await processCheckout(shipping, 'stripe');
if (!result.success) console.error(result.error);
};
if (items.length === 0) return <p>Your cart is empty.</p>;
return (
<form onSubmit={submit}>
<input required value={shipping.firstName} onChange={set('firstName')} placeholder="First name" />
<input required value={shipping.lastName} onChange={set('lastName')} placeholder="Last name" />
<input required type="email" value={shipping.email} onChange={set('email')} placeholder="E-mail" />
<input required value={shipping.address} onChange={set('address')} placeholder="Address" />
<input required value={shipping.city} onChange={set('city')} placeholder="City" />
<input required value={shipping.state} onChange={set('state')} placeholder="State" />
<input required value={shipping.zip} onChange={set('zip')} placeholder="Postcode" />
<dl>
<dt>Subtotal</dt><dd>{money(subtotal)}</dd>
{discount > 0 && (<><dt>Discount</dt><dd>−{money(discount)}</dd></>)}
<dt>Shipping</dt><dd>{money(shippingCost)}</dd>
{/* Use `total` as-is: it already includes discount and shipping. */}
<dt>Total</dt><dd>{money(total)}</dd>
</dl>
<button type="submit" disabled={loading}>
{loading ? 'Processing…' : `Pay ${money(total)}`}
</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
The adapter
An adapter turns the cart into whatever your provider needs. It runs in the browser, so it must talk to your server — never put a secret key here.
src/pagarmeAdapter.ts
import type { PaymentGatewayAdapter } from '@neverleans-labs/plug-store-core';
export const pagarmeAdapter: PaymentGatewayAdapter = async (payload) => {
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: Math.round(payload.total * 100),
customer: {
name: `${payload.shippingInfo.firstName} ${payload.shippingInfo.lastName}`,
email: payload.shippingInfo.email,
},
items: payload.items.map((item) => ({
code: item.product.id,
description: item.product.name,
amount: Math.round(item.product.price * 100),
quantity: item.quantity,
})),
notes: payload.notes,
}),
});
if (!res.ok) {
return { success: false, error: `Payment provider returned ${res.status}` };
}
const data = await res.json();
return { success: true, orderId: data.id, paymentUrl: data.checkout_url };
} catch (err) {
// Never throw from an adapter — processCheckout surfaces this as `error`.
return { success: false, error: err instanceof Error ? err.message : 'Network error' };
}
};
Wire it in:
const { processCheckout } = useCheckout({
adapters: { stripe: pagarmeAdapter },
autoRedirect: true,
});
Routing to your page
Use CatalogProvider and your own routes, keeping the PlugStore pages you still
want:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import {
CatalogProvider,
Header,
Footer,
HomePage,
ProductsPage,
ProductDetailPage,
CartPage,
} from '@neverleans-labs/plug-store-core';
import { MyCheckout } from './MyCheckout';
export default function App() {
return (
<CatalogProvider config={{ companyName: 'Bloom Cosmetics', currency: 'BRL' }}>
<BrowserRouter>
<Header />
<main>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/products/:id" element={<ProductDetailPage />} />
<Route path="/cart" element={<CartPage />} />
<Route path="/checkout" element={<MyCheckout />} />
</Routes>
</main>
<Footer />
</BrowserRouter>
</CatalogProvider>
);
}
What you still get for free
- Cart state, totals and coupon handling
begin_checkoutandpurchaseanalytics eventscreateOrderon your data provider- Cart clearing on success
- Automatic redirect when the adapter returns a
paymentUrl