Skip to content

Instantly share code, notes, and snippets.

@aravindanve
Last active April 17, 2024 13:47
Show Gist options
  • Save aravindanve/bd2caef5ed080275f14321a629f39fd6 to your computer and use it in GitHub Desktop.
Save aravindanve/bd2caef5ed080275f14321a629f39fd6 to your computer and use it in GitHub Desktop.
Custom input for payone hosted tokenization for antd form and step form
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
import { PayoneHostedTokenization } from "src/types/payoneHostedTokenization";
import styles from "./styles.module.css";
import { Button, Flex, Space, Spin, Typography, theme as antdTheme } from "antd";
import { CheckCircleFilled, CloseCircleFilled } from "@ant-design/icons";
import { apiClient } from "src/clients/apiClient";
import { useLocalize } from "src/plugins/locale/react/useLocalize";
const DIV_PAYONE_HOSTED_TOKENIZATION_ID = "payone-hosted-tokenization";
declare global {
// for more options
// see https://developer.payone.com/en/integration/basic-integration-methods/hosted-tokenization-page#managecardholdername
interface TokenizerOptions {
hideCardholderName?: boolean;
validationCallback?: (result: { valid: boolean }) => void;
// for payment product codes
// see https://developer.payone.com/en/payment-methods-and-features/payment-methods/index
paymentProductUpdatedCallback?: (result: { selectedPaymentProduct: number }) => void;
}
interface TokenizationResult {
success: boolean;
hostedTokenizationId?: string;
error?: {
message: string;
};
}
class Tokenizer {
constructor(tokenizationUrl: string, placeholder: string, options: TokenizerOptions);
initialize(): Promise<void>;
submitTokenization(): Promise<TokenizationResult>;
destroy(): void;
__submitted?: boolean; // for tracking submitted
}
interface Window {
__debugTokenizer: Tokenizer | undefined; // for debugging
}
}
export type PayoneHostedTokenizationInputStatus = "ready" | "submitted";
export type PayoneHostedTokenizationInputValue = {
id?: string;
status: PayoneHostedTokenizationInputStatus;
submit: () => Promise<void>;
};
export type PayoneHostedTokenizationInputProps = {
value?: PayoneHostedTokenizationInputValue;
onChange?: (value: PayoneHostedTokenizationInputValue | undefined) => void;
};
/**
* Custom input for payone hosted tokenization.
*
* ### Usage
* To enable the input to work with regular antd forms as well as step form,
* the input exposes a `submit()` function in the value, which can be called inside a form validator.
* This will ensure the payment method is automatically tokenized before going to
* the next step or submitting the form. However, the validator must be set to trigger
* on form submit only.
* ```tsx
* <Form.Item
* name="payoneHostedTokenization"
* help={false} // disable error messages, already shown inside the input
* rules={[
* { required: true },
* {
* validateTrigger: [], // only on form next or submit
* validator: async (_, value?: PayoneHostedTokenizationInputValue) => value?.submit(),
* },
* ]}
* >
* <PayoneHostedTokenization />
* </Form.Item>
* ```
*/
export default function PayoneHostedTokenizationInput({ value, onChange }: PayoneHostedTokenizationInputProps) {
const localize = useLocalize();
const token = antdTheme.useToken().token;
const [scriptReady, setStriptReady] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
type InternalState =
| { status: "loading" }
| { status: "error" }
| { status: "ready"; tokenizer: Tokenizer }
| { status: "submitted"; tokenizationId: string };
// initial status of ready is not supported so fallback to loading
const [internalState, setInternalState] = useState<InternalState>(
value?.status === "submitted"
? {
status: "submitted",
tokenizationId: value.id as string,
}
: {
status: "loading",
},
);
// reference to current value
const internalValueRef = useRef(value);
// update state and trigger on change
const triggerInternalStateChange = useCallback(
(newState: InternalState) => {
let newValue: PayoneHostedTokenizationInputValue | undefined;
if (newState.status === "loading") {
newValue = undefined;
} else if (newState.status === "error") {
newValue = undefined;
} else if (newState.status === "ready") {
newValue = {
id: undefined,
status: "ready",
submit: async () => {
if (!newState.tokenizer.__submitted) {
const result = await newState.tokenizer.submitTokenization();
if (result.error) {
setErrorMessage(result.error.message);
throw new Error();
} else {
newState.tokenizer.__submitted = true; // prevents double submit before next render
triggerInternalStateChange({
status: "submitted",
tokenizationId: result.hostedTokenizationId as string,
});
}
}
},
};
} else {
// mutate current value to prevent triggering onChange
// NOTE: the submitted state is triggered after the final validation on form submit.
// triggering onChange at this time would prevent the form submit from going though,
// and the user would have to click submit once more to submit the form.
newValue = internalValueRef.current!;
newValue.id = newState.tokenizationId;
newValue.status = "submitted";
newValue.submit = async () => undefined;
}
// set internal state and value
setInternalState(newState);
internalValueRef.current = newValue;
// trigger onChnage unless state is submitted
if (newState.status !== "submitted") {
onChange?.(newValue);
}
},
[onChange],
);
// trigger reset from submitted or error state
const triggerReset = () => {
// trigger loading state
triggerInternalStateChange({
status: "loading",
});
};
// initialize tokenizer
useEffect(() => {
if (internalState.status === "loading" && scriptReady) {
let destroyed = false;
(async () => {
try {
// create tokenization url
const res = await apiClient.post<PayoneHostedTokenization>("v1/payment/all/payone-hosted-tokenization");
if (destroyed) {
return;
}
// create tokenizer
const instance = new Tokenizer(res.data.payoneHostedTokenizationUrl, DIV_PAYONE_HOSTED_TOKENIZATION_ID, {
hideCardholderName: false,
validationCallback: ({ valid }) => valid && setErrorMessage(""),
});
// debug tokenizer
window.__debugTokenizer = instance;
// initialize tokenizer
await instance.initialize();
if (destroyed) {
instance.destroy();
return;
}
// trigger ready state
triggerInternalStateChange({
status: "ready",
tokenizer: instance,
});
} catch (error) {
console.error("Error creating payone hosted tokenization", error);
if (destroyed) {
return;
}
// trigger error state
triggerInternalStateChange({
status: "error",
});
}
})();
return () => {
destroyed = true;
};
}
}, [internalState.status, scriptReady, triggerInternalStateChange]);
// destroy tokenizer
useEffect(() => {
if (internalState.status === "ready") {
return () => {
internalState.tokenizer.destroy();
};
}
}, [internalState]);
return (
<>
<Script
src="https://payment.preprod.payone.com/hostedtokenization/js/client/tokenizer.min.js"
onReady={() => setStriptReady(true)}
/>
{errorMessage && <Typography.Text type="danger">{errorMessage}</Typography.Text>}
<div
id={DIV_PAYONE_HOSTED_TOKENIZATION_ID}
className={styles.paymentHostedTokenizationPlaceholder}
style={{
...(internalState.status === "ready" && {
minHeight: 300,
}),
}}
>
{internalState.status === "loading" ? (
<Flex justify="center" style={{ padding: 48 }}>
<Spin />
</Flex>
) : internalState.status === "submitted" ? (
<Space>
<CheckCircleFilled style={{ fontSize: 24, color: token.colorSuccess }} />
<Typography.Text style={{ fontSize: 16 }}>
{localize("resource.payoneHostedTokenization.paymentMethodAddedText")}
</Typography.Text>
<Button type="link" size="small" onClick={triggerReset}>
{localize("resource.payoneHostedTokenization.paymentMethodEditButton")}
</Button>
</Space>
) : internalState.status === "error" ? (
<Space>
<CloseCircleFilled style={{ fontSize: 24, color: token.colorError }} />
<Typography.Text style={{ fontSize: 16 }}>
{localize("resource.payoneHostedTokenization.paymentMethodErrorText")}
</Typography.Text>
<Button type="link" size="small" onClick={triggerReset}>
{localize("resource.payoneHostedTokenization.paymentMethodResetButton")}
</Button>
</Space>
) : null}
</div>
</>
);
}
.paymentHostedTokenizationPlaceholder iframe {
border: none;
width: 380px;
max-width: 100%;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment