Last active
April 17, 2024 13:47
-
-
Save aravindanve/bd2caef5ed080275f14321a629f39fd6 to your computer and use it in GitHub Desktop.
Custom input for payone hosted tokenization for antd form and step form
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | |
</> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.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