Skip to content

Commit

Permalink
Setup Checkout (Update Payment Method) (#36)
Browse files Browse the repository at this point in the history
* impl setup checkout

* update example

* fix onload err

* remove

* update names

* update logs

* update shared model

* resolve comments"

* update event name

* use format utils
  • Loading branch information
zeyarpaing authored Sep 12, 2024
1 parent 417b378 commit 2ec8d17
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 32 deletions.
15 changes: 14 additions & 1 deletion example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@getopenpay/openpay-js-react": "file:..",
"@stripe/stripe-js": "^4.3.0",
"classnames": "^2.5.1",
"fraction.js": "^4.3.7",
"next": "14.2.5",
"react": "^18",
"react-dom": "^18"
Expand Down
85 changes: 61 additions & 24 deletions example/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use client';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, useCallback, useEffect, useState } from 'react';
import {
ElementsForm,
CardCvcElement,
Expand All @@ -12,17 +12,21 @@ import InputField from '@/components/input-field';
import BillingDetails from '@/components/billing-details';
import classNames from 'classnames';
import { loadStripe } from '@stripe/stripe-js';
import { CurrencySymbolMap } from '@/utils/currency';
import { atomToCurrency } from '@/utils/math';

type OnCheckoutSuccess = (invoiceUrls: string[], subscriptionIds: string[], customerId: string) => void;

type OnSetupPaymentMethodSuccess = (paymentMethodId: string) => void;
interface FormProps {
token: string;
separateFrames: boolean;
onCheckoutSuccess: OnCheckoutSuccess;
onSetupPaymentMethodSuccess: OnSetupPaymentMethodSuccess;
baseUrl?: string;
}

const Form: FC<FormProps> = (props) => {
const { token, separateFrames, onCheckoutSuccess } = props;
const { token, separateFrames, onCheckoutSuccess, onSetupPaymentMethodSuccess: onSetupCheckoutSuccess } = props;
const [loading, setLoading] = useState<boolean>(true);
const [amount, setAmount] = useState<string | null>(null);
const [overlayMessage, setOverlayMessage] = useState<{
Expand All @@ -31,15 +35,6 @@ const Form: FC<FormProps> = (props) => {
} | null>(null);

const [validationErrors, setValidationErrors] = useState<Record<string, string[]>>({});
const validationError = useMemo(() => {
if (!validationErrors) return null;

const errorMessages = Object.entries(validationErrors).map(([elementType, errors]) => {
return `${elementType}: ${errors.join(', ')}`;
});

return errorMessages.join('; ');
}, [validationErrors]);

const resetErrors = useCallback(() => {
setValidationErrors({});
Expand All @@ -62,10 +57,14 @@ const Form: FC<FormProps> = (props) => {
});
};

const onLoad = (totalAmountAtoms: number, currency?: string): void => {
const onLoad = (totalAmountAtoms?: number, loadedCurrency?: string): void => {
setLoading(false);
resetErrors();
setAmount(`${currency ? `${currency.toUpperCase()} ` : '$'}${totalAmountAtoms / 100}`);
if (totalAmountAtoms) {
const currency = loadedCurrency ?? 'usd';
const amount = CurrencySymbolMap[currency] + atomToCurrency(totalAmountAtoms, currency);
setAmount(amount);
}
};

const onLoadError = (message: string): void => {
Expand All @@ -85,6 +84,9 @@ const Form: FC<FormProps> = (props) => {

useEffect(() => {
console.log(`Stripe JS can be loaded as a <script> in head (recommended), or loaded through ${loadStripe.name}`);
if (!token) {
return;
}
setLoading(true);
setOverlayMessage(null);
}, [token]);
Expand All @@ -98,18 +100,23 @@ const Form: FC<FormProps> = (props) => {
onValidationError={onValidationError}
onCheckoutStarted={onCheckoutStarted}
onCheckoutSuccess={onCheckoutSuccess}
onSetupPaymentMethodSuccess={(paymentMethodID) => {
onSetupCheckoutSuccess(paymentMethodID);
}}
onCheckoutError={onCheckoutError}
>
{({ submit, applePay, googlePay }) => (
<FormWrapper error={validationError}>
{(loading || overlayMessage) && (
<div
data-testid="overlay"
className="w-full py-2 my-2 h-full flex flex-col gap-2 items-center justify-center bg-emerald-100/50 dark:bg-emerald-800/50 backdrop-blur rounded-lg cursor-not-allowed"
>
{loading && <span className="text-xl animate-spin">⏳︎</span>}
<pre data-opid="overlay-message" className="block font-bold max-w-md w-full text-wrap my-3">
{JSON.stringify(overlayMessage ?? '', null, 2)}
<FormWrapper error={validationErrors}>
{loading && (
<div data-testid="loading" className="flex items-center">
<span className="text-xl animate-spin">⏳︎</span>
Loading...
</div>
)}
{overlayMessage && (
<div className="w-full py-2 my-2 h-full flex items-center justify-center bg-emerald-100/50 dark:bg-emerald-800/50 backdrop-blur rounded-lg cursor-not-allowed">
<pre data-testid="overlay-message" className="block font-bold max-w-md w-full text-wrap my-3">
{JSON.stringify(overlayMessage, null, 2)}
</pre>
</div>
)}
Expand Down Expand Up @@ -180,17 +187,28 @@ const Form: FC<FormProps> = (props) => {
const ElementsExample: FC = () => {
const [token, setToken] = useState<string | null>(null);
const [separateFrames, setSeparateFrames] = useState<boolean>(false);
const [baseUrl, setBaseUrl] = useState<string>();
const [checkoutResponse, setCheckoutResponse] = useState<{
invoiceUrls: string[];
subscriptionIds: string[];
customerId: string;
} | null>(null);
const [setupResponse, setSetupResponse] = useState<{
paymentMethodId: string;
} | null>(null);

const onCheckoutSuccess = useCallback<OnCheckoutSuccess>((invoiceUrls, subscriptionIds, customerId) => {
setCheckoutResponse({ invoiceUrls, subscriptionIds, customerId });
setToken(null);
}, []);

const onSetupCheckoutSuccess = useCallback<OnSetupPaymentMethodSuccess>((paymentMethodId) => {
setSetupResponse({
paymentMethodId,
});
setToken(null);
}, []);

const onTokenChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setCheckoutResponse(null);
setToken(e.target.value === '' ? null : e.target.value);
Expand All @@ -201,13 +219,17 @@ const ElementsExample: FC = () => {
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
const separateFrames = urlParams.get('separateFrames');
const baseUrl = urlParams.get('baseUrl');

if (token) {
setToken(token);
}
if (separateFrames) {
setSeparateFrames(separateFrames === 'true');
}
if (baseUrl) {
setBaseUrl(baseUrl);
}
}, []);

return (
Expand Down Expand Up @@ -245,7 +267,13 @@ const ElementsExample: FC = () => {

{token ? (
<div className="relative">
<Form token={token} separateFrames={separateFrames} onCheckoutSuccess={onCheckoutSuccess} />
<Form
token={token}
separateFrames={separateFrames}
onCheckoutSuccess={onCheckoutSuccess}
onSetupPaymentMethodSuccess={onSetupCheckoutSuccess}
baseUrl={baseUrl}
/>
</div>
) : (
<div className="mb-4">
Expand Down Expand Up @@ -281,6 +309,15 @@ const ElementsExample: FC = () => {
</p>
</>
)}
{setupResponse && (
<>
<h2 className="text-xl font-bold">🎉 Setup/Update successful!</h2>
<p className="my-2">Payment method ID:</p>
<p data-testid="payment-method-id" className="text-sm">
{setupResponse.paymentMethodId}
</p>
</>
)}
</main>
);
};
Expand Down
9 changes: 7 additions & 2 deletions example/src/components/form-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FC, PropsWithChildren } from 'react';

interface FormWrapperProps extends PropsWithChildren {
error: string | null;
error?: Record<string, string[]>;
}

const FormWrapper: FC<FormWrapperProps> = (props) => {
Expand All @@ -12,7 +12,12 @@ const FormWrapper: FC<FormWrapperProps> = (props) => {
<div className="max-w-lg w-full mb-4">{children}</div>

{error ? (
<p className="text-red-600 dark:text-red-400 font-bold text-xs">{error}</p>
<pre
data-testid="validation-error"
className="text-red-600 dark:text-red-400 font-bold text-xs block text-wrap"
>
{JSON.stringify(error)}
</pre>
) : (
<p className="text-emerald-800 dark:text-emerald-400 text-xs">You won&apos;t be charged real money.</p>
)}
Expand Down
16 changes: 16 additions & 0 deletions example/src/utils/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const CurrencyEnum = {
Usd: 'usd',
Brl: 'brl',
} as const;
export type CurrencyEnum = (typeof CurrencyEnum)[keyof typeof CurrencyEnum];

// Define conversion rates or atomic units for different currencies
export const ConversionRates: Record<string, number> = {
[CurrencyEnum.Usd]: 100, // e.g., 1 USD = 100 atoms (cents)
[CurrencyEnum.Brl]: 100, // e.g., 1 BRL = 100 atoms (centavos)
};

export const CurrencySymbolMap: Record<string, string> = {
[CurrencyEnum.Usd]: `$`,
[CurrencyEnum.Brl]: `R$`,
};
10 changes: 10 additions & 0 deletions example/src/utils/math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Fraction from 'fraction.js';
import { ConversionRates } from './currency';

export const sum = (arr: number[]): number => {
return arr.reduce((a, b) => a + b, 0);
};

export const atomToCurrency = (num: number, currency: string): number => {
return new Fraction(num).div(ConversionRates[currency]).valueOf();
};
21 changes: 18 additions & 3 deletions lib/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
onCheckoutStarted,
onCheckoutSuccess,
onCheckoutError,
onSetupPaymentMethodSuccess,
baseUrl,
} = props;

Expand Down Expand Up @@ -197,13 +198,20 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
setTokenized(totalTokenized);
}
} else if (eventType === EventType.enum.CHECKOUT_SUCCESS) {
console.log('[form] Checkout complete:', eventPayload.invoiceUrls);
console.log('[form] Checkout complete:', eventPayload);
setPreventClose(false);
setTokenized(0);
setCheckoutFired(false);

if (onCheckoutSuccess)
onCheckoutSuccess(eventPayload.invoiceUrls, eventPayload.subscriptionIds, eventPayload.customerId);
} else if (eventType === EventType.enum.SETUP_PAYMENT_METHOD_SUCCESS) {
console.log('[form] Setup payment method complete:', eventPayload);
setPreventClose(false);
setTokenized(0);
setCheckoutFired(false);

if (onSetupPaymentMethodSuccess) onSetupPaymentMethodSuccess(eventPayload.paymentMethodId);
} else if (eventType === EventType.enum.LOAD_ERROR) {
console.error('[form] Error loading iframe:', eventPayload.message);

Expand Down Expand Up @@ -262,6 +270,7 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
onLoadError,
onCheckoutStarted,
onCheckoutSuccess,
onSetupPaymentMethodSuccess,
onCheckoutError,
onValidationError,
frameBaseUrl,
Expand Down Expand Up @@ -307,12 +316,18 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
[preventClose]
);

/**
* Check if all iframes have loaded and invoke `onLoad` callback
*/
useEffect(() => {
if (loaded || !formRef.current || !totalAmountAtoms) return;
if (loaded || !formRef.current) return;

const areIframesLoaded = iframes.length > 0 && iframes.length === Object.keys(eventTargets).length;

if (iframes.length === Object.keys(eventTargets).length) {
if (areIframesLoaded) {
console.log('[form] All elements loaded');
setLoaded(true);
// Total amount will be undefined if mode is 'setup'
if (onLoad) onLoad(totalAmountAtoms, currency);
}
}, [iframes, eventTargets, loaded, onLoad, totalAmountAtoms, currency]);
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@ export type ElementsFormProps = {
onFocus?: (elementId: string) => void;
onBlur?: (elementId: string) => void;
onChange?: (elementId: string) => void;
onLoad?: (totalAmountAtoms: number, currency?: string) => void;
onLoad?: (totalAmountAtoms?: number, currency?: string) => void;
onLoadError?: (message: string) => void;
onValidationError?: (field: AllFieldNames, errors: string[], elementId?: string) => void;
onCheckoutStarted?: () => void;
onCheckoutSuccess?: (invoiceUrls: string[], subscriptionIds: string[], customerId: string) => void;
onSetupPaymentMethodSuccess?: (paymentMethodId: string) => void;
onCheckoutError?: (message: string) => void;
baseUrl?: string;
};
11 changes: 10 additions & 1 deletion lib/utils/shared-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const EventType = z.enum([
'VALIDATION_ERROR',
'TOKENIZE_ERROR',
'CHECKOUT_ERROR',
'SETUP_PAYMENT_METHOD_SUCCESS',

// Form -> Element
'TOKENIZE',
Expand Down Expand Up @@ -171,6 +172,13 @@ export const PaymentFlowStartedEventPayload = z.object({
});
export type PaymentFlowStartedEventPayload = z.infer<typeof PaymentFlowStartedEventPayload>;

export const SetupCheckoutSuccessEventPayload = z.object({
type: z.literal(EventType.enum.SETUP_PAYMENT_METHOD_SUCCESS),
paymentMethodId: z.string(),
});

export type SetupCheckoutSuccessEventPayload = z.infer<typeof SetupCheckoutSuccessEventPayload>;

export const RequiredFormFields = z.object({
[FieldName.FIRST_NAME]: RequiredString,
[FieldName.LAST_NAME]: RequiredString,
Expand All @@ -189,7 +197,7 @@ export const SubmitEventPayload = z
sessionId: RequiredString,
checkoutPaymentMethod: CheckoutPaymentMethod,
paymentFlowMetadata: z.any().optional(),
doNotUseLegacyCCFlow: z.boolean(),
doNotUseLegacyCCFlow: z.boolean().optional(),
existingCCPMId: OptionalString,
})
.extend(RequiredFormFields.shape);
Expand Down Expand Up @@ -220,6 +228,7 @@ export const EventPayload = z.discriminatedUnion('type', [
TokenizeSuccessEventPayload,
CheckoutSuccessEventPayload,
PaymentFlowStartedEventPayload,
SetupCheckoutSuccessEventPayload,
]);
export type EventPayload = z.infer<typeof EventPayload>;

Expand Down

0 comments on commit 2ec8d17

Please sign in to comment.