@superlogic/spree-pay
v0.5.7
Published
Spree-pay React component and utilities
Keywords
Readme
@superlogic/spree-pay
Publishable React package exposing the SpreePay component and related utilities.
Install
npm i @superlogic/spree-payUsage
import { useState } from 'react';
import { SpreePay, SpreePayProvider, useSpreePay } from '@superlogic/spree-pay';
import { useCapture3DS } from '@superlogic/spree-pay';
import '@superlogic/spree-pay/styles.css';
const spreeEnv = {
tenantId: 'your-tenant-id', // Tenant identifier provided during onboarding
environment: 'dev' as const,
redirect3dsURI: '/3ds', // Any valid URI in your app that captures 3ds redirect
ssoPageURI: '/silent-check-sso.html', // Any valid URI in your app that will handle Keycloak SSO (example below)
};
function Checkout() {
return (
<SpreePayProvider env={spreeEnv}>
<CheckoutContent />
</SpreePayProvider>
);
}
function CheckoutContent() {
const { process, selectedPaymentMethod, isProcessing, enabled } = useSpreePay();
const [status, setStatus] = useState<'idle' | 'processing' | 'success' | 'error'>('idle');
async function handleProcess() {
if (!enabled) {
console.log('Please select a payment method');
return;
}
setStatus('processing');
try {
// Example: create order and process payment
const order = await getOrderHash();
const result = await process({
hash: order.hash,
capture: true, // Optional: auto-capture payment
});
console.log('Payment result:', result);
// result.status: AUTHORIZED or CAPTURED
// result.paymentId: unique payment identifier
// result.paymentType: CREDIT_CARD, CRYPTO, CDC, etc.
setStatus('success');
} catch (err) {
// `process` always rejects with a PaymentError — see "Error Handling" below
console.error('Payment failed:', err);
setStatus('error');
}
}
return (
<div>
<SpreePay
amount={99}
onProcess={handleProcess}
transactionFeePercentage={0.04}
className="w-full max-w-[540px]"
isProcessing={status === 'processing'}
disabledPoints={false}
topUpLink="/top-up"
/>
{status === 'success' && <p>Payment successful! Thank you for your order.</p>}
{status === 'error' && <p>Payment failed. Please try again.</p>}
</div>
);
}
// example 3DS redirect page hosted on /3ds
export default function ThreeDSRedirectPage() {
// take payment intent from search params as payment_intent
const searchParams = exampleGetSearchParams();
// Pass the entire search params object to the hook
useCapture3DS({ paymentIntent: searchParams['payment_intent'] });
// Render null or any other UI/Loader
return null;
}SpreePay props
type SpreePayProps = {
amount?: number; // USD amount to charge (e.g., 99 for $99). Used for display and fee calculation.
onProcess?: () => Promise<void>; // Called when the Checkout button is clicked. Implement your order flow here and call useSpreePay().process({ hash, ... }).
isProcessing?: boolean; // Shows a loading state and disables interactions when true (useful while your onProcess runs).
transactionFeePercentage?: number; // Fee multiplier applied to the USD portion (e.g., 0.04 for 4%).
disabledPoints?: boolean; // Disables points payment option if true.
topUpLink?: string; // Optional link to top-up page for points balance.
className?: string; // Optional wrapper class for layout/styling.
// Multi-currency display (all three fields should be supplied together)
currencyCode?: string; // ISO 4217 display currency (e.g. "AUD").
foreignCurrencyAmount?: number; // Pre-fee order total in the display currency (e.g. 1280.46). Used directly as Pay button base; transaction fee is added on top.
exchangeRate?: number; // 1 unit of currencyCode in USD (e.g. 0.65 for AUD→USD). Used to convert USD remainder when points are applied.
origin?: string; // Optional source identifier (e.g. ONE_PLATFORM)
};Multi-currency display
When currencyCode, foreignCurrencyAmount, and exchangeRate are all provided the widget shows prices in the shopper-selected currency. Settlement and points calculations continue in USD.
<SpreePay
amount={832.3} // USD amount — used for points math and payment-intent creation
currencyCode="AUD"
foreignCurrencyAmount={1280.46} // pre-fee order total in AUD from your backend
exchangeRate={0.65} // 1 AUD = 0.65 USD
onProcess={handleProcess}
transactionFeePercentage={0.04}
className="w-full max-w-[540px]"
isProcessing={status === 'processing'}
/>Display behaviour:
- Pay button (no points) — shows
foreignCurrencyAmount + feein the display currency - Pay button (split) — shows the card portion converted from USD remainder + fee
- Points slider — card amount updates live in the display currency as the slider moves
- Available points value — shown in display currency alongside the points count
After the user commits the points slider, selectedPaymentMethod exposes:
amount— USD card amount including fee (use for payment-intent creation)foreignCurrencyAmount— card amount in display currency, 2 d.p. (use for order display / receipts)
If any of the three display params are absent the widget falls back to USD-only display — no breaking change.
API Reference
SpreePayProvider Props
type ENV = {
environment: 'dev' | 'stg' | 'prod'; // Environment for API endpoints and logging
tenantId: string; // Tenant identifier
ssoPageURI: string; // Path to Keycloak SSO page (e.g., '/silent-check-sso.html')
redirect3dsURI: string; // Path to 3DS redirect handler (e.g., '/3ds')
accessToken?: string; // Optional: provide pre-authenticated token to skip Keycloak SSO
useWeb3Points?: boolean; // Optional: enable Web3-based points (default: true)
keycloakClientId?: string; // Optional: custom Keycloak client ID
};useSpreePay Hook
const {
process, // Function to trigger payment processing
isProcessing, // Internal processing state (true while payment is being processed)
enabled, // true if a payment method is selected and ready
selectedPaymentMethod, // Currently selected payment method details
} = useSpreePay();
// process function signature:
async function process(params: ProcessFnParams): Promise<PaymentMethodResult>;
type ProcessFnParams = {
hash: string; // Order hash from your backend
capture?: boolean; // Optional: auto-capture payment (default: false)
};
type PaymentMethodResult = {
status: PaymentStatus; // Payment status (AUTHORIZED, CAPTURED, FAILED, etc.)
paymentId: string; // Unique payment identifier
txHash: string | null; // Transaction hash (for crypto payments)
txId: string | null; // Transaction ID
paymentType: PaymentType; // Type of payment used
};Payment Types & Statuses
enum PaymentType {
CREDIT_CARD = 'CREDIT_CARD',
CRYPTO = 'CRYPTO',
CDC = 'CDC', // Crypto.com Pay
CREDIT_CARD_SPLIT = 'SPLIT', // Split payment (card + points)
POINTS = 'POINTS',
}
enum PaymentStatus {
AUTHORIZED = 'AUTHORIZED', // Payment authorized (success)
CAPTURED = 'CAPTURED', // Payment captured (success)
FAILED = 'FAILED', // Payment failed
PENDING = 'PENDING', // Payment pending (backend only)
NOT_INITIALIZED = 'NOT_INITIALIZED', // Not yet started (backend only)
VOIDED = 'VOIDED', // Payment voided (backend only)
CAPTURE_FAILED = 'CAPTURE_FAILED', // Capture failed (backend only)
}Error Handling
process() always rejects with a PaymentError. Branch on code (the failure category) to show your own UI copy. The original error — including any raw backend detail — is kept on cause for logging only; never render it to end users.
| code | When | Suggested UX |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| PaymentErrorCode.CARD_ERROR | A card payment was rejected by the backend (a 4xx response, or a terminal FAILED status on a card flow). | "We couldn't process your card — check it / try another." |
| PaymentErrorCode.PAYMENT_ERROR | Anything else: network/server error, timeout, cancelled flow, or a crypto/points failure. | Generic "payment failed, try again". |
Safety:
messageis a fixed, generic label (e.g."Card payment failed") — it never contains raw backend text, so it's safe to log or show. To distinguish a real decline (e.g. "insufficient funds") from another 4xx, the backend must return a machine-readable error code; the widget intentionally does not surface the backend's free-text message to end users.
import { PaymentError, PaymentErrorCode, SpreePayHttpError, isPaymentError } from '@superlogic/spree-pay';
try {
const result = await process({ hash, capture: true });
// result.status is AUTHORIZED or CAPTURED
} catch (err) {
if (isPaymentError(err)) {
// Choose user-facing copy from the category — do NOT show err.cause to users.
if (err.code === PaymentErrorCode.CARD_ERROR) {
// card-payment problem — prompt the user to check the card or try another
}
err.code; // PaymentErrorCode — the category to branch on
err.status; // PaymentStatus — e.g. FAILED
err.backendCode; // string | undefined — a stable backend code when available (e.g. 'INSUFFICIENT_FUNDS')
// For logging/debugging only — may carry sensitive backend detail:
if (err.cause instanceof SpreePayHttpError) {
err.cause.status; // numeric HTTP status (e.g. 400, 500)
err.cause.body; // raw response body
err.cause.url; // request URL
}
}
}enum PaymentErrorCode {
CARD_ERROR = 'CARD_ERROR', // a card payment was rejected by the backend
PAYMENT_ERROR = 'PAYMENT_ERROR', // network/server/timeout/cancelled/crypto/points failure
}
// Thrown by the widget; detect it with `isPaymentError` and read its fields.
// Not meant to be constructed by consumers.
class PaymentError extends Error {
message: string; // fixed, generic label — never raw backend text; safe to log/show
code: PaymentErrorCode; // failure category to branch on (defaults to PAYMENT_ERROR)
status: PaymentStatus; // payment status at the point of failure
backendCode?: string; // stable machine code from the backend when available (e.g. 'INSUFFICIENT_FUNDS') — for finer branching
cause?: unknown; // the original thrown value (standard Error.cause) — log only, may hold sensitive detail
}
// Type guard for consumers — the supported way to detect a PaymentError
function isPaymentError(e: unknown): e is PaymentError;
// Thrown by the fetcher on a non-ok HTTP response; reachable via PaymentError.cause
class SpreePayHttpError extends Error {
status: number; // HTTP status code
body: string; // raw response body
url: string; // request URL
}useCapture3DS Hook
// Use in your 3DS redirect page to capture payment intent and close the modal
useCapture3DS(params: { paymentIntent?: string | null });Logger API
import { LogLevel, configureLogger, logger } from '@superlogic/spree-pay';
// Configure logging behavior
configureLogger({
environment: 'dev', // 'prod' = ERROR only, 'dev'/'stg' = all logs
minLevel: LogLevel.DEBUG, // Optional: override default log level
});
// Use the logger
logger.debug('Debug message', { context: 'data' });
logger.info('Info message');
logger.warn('Warning message');
logger.error('Error message', error);
// Create child logger with prefix
const componentLogger = logger.child('MyComponent');
componentLogger.info('Component initialized');
enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
NONE = 4,
}Supporting types are exported so you can name them in your own code:
import type { Environment, LogContext, LoggerConfig } from '@superlogic/spree-pay';
type Environment = 'dev' | 'stg' | 'prod'; // also the `environment` field of the provider's ENV
type LoggerConfig = { minLevel: LogLevel; prefix: string; environment?: Environment };
type LogContext = Record<string, unknown>; // the optional context arg on logger.debug/info/warn/error
// configureLogger accepts a partial config:
function configureLogger(config: Partial<LoggerConfig> & { environment?: Environment }): void;Theme Customization
SpreePay uses CSS variables for theming (the full list is the :root defaults block in packages/spree-pay/src/styles/globals.css). You can override these using CSS or CSS-in-JS.
Important: To override the theme locally (scoped), you need more specific CSS than just .sl-spreepay. Use a wrapper class like .your-class .sl-spreepay or combine with SpreePay's className prop.
Global override (affects all SpreePay instances)
.sl-spreepay {
--primary: #272e32;
--accent: #439ef4;
--s-default: #ffffff;
}Local/scoped override
Option A: Wrap SpreePay in a container with your own class:
<div className="my-checkout">
<SpreePay {...props} />
</div>
<style>{`
.my-checkout .sl-spreepay {
--primary: #your-color;
--accent: #your-accent;
}
`}</style>Option B: Pass className to SpreePay and use combined selector:
<SpreePay className="custom-theme" {...props} />
<style>{`
.sl-spreepay.custom-theme {
--primary: #your-color;
--accent: #your-accent;
}
`}</style>Keycloak SSO page example (/silent-check-sso.html)
<!doctype html>
<html>
<body>
<script>
try {
parent.postMessage(location.href, location.origin);
} catch (e) {}
</script>
</body>
</html>