Skip to content

Commit

Permalink
[UXP-3241] Card form component wrapper (#179)
Browse files Browse the repository at this point in the history
* Creates DuffelCardForm component

* version bump

---------

Co-authored-by: Igor de Paula <[email protected]>
  • Loading branch information
andrejak and igorp1 authored Dec 14, 2023
1 parent bf060ed commit 57dcf94
Show file tree
Hide file tree
Showing 13 changed files with 297 additions and 6 deletions.
1 change: 1 addition & 0 deletions config/esbuild.cdn.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const envVariablesToDefine = {
? process.env.COMPONENT_CDN
: `${process.env.COMPONENT_CDN}/${VERSION}`
}"`,
"process.env.TOKEN_PROXY_IFRAME_BASE_URL": `"${process.env.TOKEN_PROXY_IFRAME_BASE_URL}"`,
};

esbuild
Expand Down
3 changes: 2 additions & 1 deletion config/esbuild.dev.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ const COMPONENT_CDN = process.env.COMPONENT_CDN.startsWith("http://localhost:")
(async function () {
const esbuildContext = await esbuild.context({
...require("./esbuild.base.config"),
// The `define` config will replace the values in the code with the ones we scecify below.
// The `define` config will replace the values in the code with the ones we specify below.
// This is needed since the component will be used in the browser,
// where we don't have access to environment variables.
define: {
"process.env.COMPONENT_CDN": `"${COMPONENT_CDN}"`,
"process.env.DUFFEL_API_URL": `"${DUFFEL_API_URL}"`,
"process.env.COMPONENT_VERSION": `"${VERSION}"`,
"process.env.TOKEN_PROXY_IFRAME_BASE_URL": `"${process.env.TOKEN_PROXY_IFRAME_BASE_URL}"`,
},
plugins: [
// This plugin copies the offer and seat maps fixtures to the dist folder.
Expand Down
1 change: 1 addition & 0 deletions config/esbuild.react.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const envVariablesToDefine = {
? process.env.COMPONENT_CDN
: `${process.env.COMPONENT_CDN}/${VERSION}`
}"`,
"process.env.TOKEN_PROXY_IFRAME_BASE_URL": `"${process.env.TOKEN_PROXY_IFRAME_BASE_URL}"`,
};

// Builds for react environment
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@duffel/components",
"version": "3.1.9",
"version": "3.2.0",
"description": "Component library to build your travel product with Duffel.",
"keywords": [
"Duffel",
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-and-publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ node config/esbuild.react.config.js
## For those that use typescript
tsc --project tsconfig.json
mv ./react-dist/src/* ./react-dist/
echo 'export * from "@duffel/api/types"' >> ./react-dist/types/index.d.ts
rm -rf ./react-dist/src
rm -rf ./react-dist/scripts

Expand All @@ -37,6 +38,7 @@ echo 'export * from "@duffel/api/types"' >> ./react-dist/types/index.d.ts

# Moves package json to build folder, we'll publish from it
cp package.json ./react-dist/package.json
cp README.md ./react-dist/README.md

# Moves readme file so when we publish from the r4eact-dist folder our npm page will have all the info people need
cp README.md ./react-dist/README.md
Expand Down
99 changes: 99 additions & 0 deletions src/components/DuffelCardForm/DuffelCardForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as React from "react";
import { getTokenFromClientKey } from "./lib/getTokenFromClientKey";
import { DuffelCardFormProps } from "./lib/types";
import { getIFrameEventListener } from "./lib/getIFrameEventListener";

const LOCAL_TOKEN_PROXY_IFRAME_BASE_URL = "https://localhost:8000/iframe.html";

export const DuffelCardForm: React.FC<DuffelCardFormProps> = ({
clientKey,
styles,
shouldUseLocalTokenProxy,

actions,

onValidateSuccess,
onValidateFailure,

onCreateCardForTemporaryUseSuccess,
onCreateCardForTemporaryUseFailure,
}) => {
const baseUrlString = shouldUseLocalTokenProxy
? LOCAL_TOKEN_PROXY_IFRAME_BASE_URL
: process.env.TOKEN_PROXY_IFRAME_BASE_URL;

if (typeof baseUrlString !== "string") {
throw new Error("TOKEN_PROXY_IFRAME_BASE_URL is not defined");
}

const baseUrl = new URL(baseUrlString);

const [iFrameHeight, setIFrameHeight] = React.useState("0px");

const iFrameReference = React.useRef<HTMLIFrameElement>(null);

const params: Record<string, string> = {
token: getTokenFromClientKey(clientKey),
...(styles?.fontFamily && { font: styles?.fontFamily }),
...(styles?.stylesheetUrl && { stylesheet: styles?.stylesheetUrl }),
};

const iFrameSrc = `${baseUrl}?${new URLSearchParams(params).toString()}`;

/**
* Adds an event listener to the window to listen to messages from the iframe.
*/
React.useEffect(() => {
const iFrameEventListener = getIFrameEventListener(baseUrl.origin, {
setIFrameHeight,
onValidateSuccess,
onValidateFailure,
onCreateCardForTemporaryUseSuccess,
onCreateCardForTemporaryUseFailure,
});
window.addEventListener("message", iFrameEventListener);
return () => window.removeEventListener("message", iFrameEventListener);
}, []);

function sendMessageToStoreCardForTemporaryUse() {
if (!iFrameReference.current) {
throw new Error(
"Attempted to call `sendMessageToStoreCardForTemporaryUse` with empty iFrameReference"
);
}

const iFrame = iFrameReference.current;
if (!iFrame.contentWindow) {
throw new Error(
"Attempted to call `sendMessageToStoreCardForTemporaryUse` but the iFrame contentWindow is null"
);
}

iFrame.contentWindow.postMessage(
{ type: "create-card-for-temporary-use" },
baseUrl.origin
);
}

/**
* useEffect to react to changes on the actions prop.
*/
React.useEffect(() => {
if (actions.includes("create-card-for-temporary-use")) {
sendMessageToStoreCardForTemporaryUse();
}
}, [actions]);

return (
<iframe
ref={iFrameReference}
title="Card Payment Form"
src={iFrameSrc}
style={{
width: "100%",
border: "none",
height: iFrameHeight,
}}
/>
);
};
57 changes: 57 additions & 0 deletions src/components/DuffelCardForm/lib/getIFrameEventListener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { DuffelCardFormProps } from "./types";

type Inputs = {
setIFrameHeight: (height: string) => void;
} & Pick<
DuffelCardFormProps,
| "onValidateSuccess"
| "onValidateFailure"
| "onCreateCardForTemporaryUseSuccess"
| "onCreateCardForTemporaryUseFailure"
>;

export function getIFrameEventListener(
baseUrl: string,
{
setIFrameHeight,
onValidateSuccess,
onValidateFailure,
onCreateCardForTemporaryUseSuccess,
onCreateCardForTemporaryUseFailure,
}: Inputs
) {
return function iFrameEventListener(event: MessageEvent) {
if (!baseUrl?.startsWith(event.origin) || !event.data || !event.data.type) {
return;
}

const eventType = event?.data?.type;

switch (eventType) {
case "iframe-loaded":
setIFrameHeight(event.data.height);
return;

case "validate-success":
onValidateSuccess();
return;

case "validate-failure":
onValidateFailure();
return;

case "create-card-for-temporary-use-success":
onCreateCardForTemporaryUseSuccess(event.data.data);
return;

case "create-card-for-temporary-use-failure":
onCreateCardForTemporaryUseFailure(event.data.error);
return;

default:
// eslint-disable-next-line
console.log(`Unknown event type: ${eventType}`);
return;
}
};
}
18 changes: 18 additions & 0 deletions src/components/DuffelCardForm/lib/getTokenFromClientKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function getTokenFromClientKey(clientKey: string): string {
if (clientKey.split(".").length !== 3) {
throw new Error(
"Invalid clientKey attribute in DuffelCardForm. It must be a valid JWT."
);
}

const payloadString = clientKey.split(".")[1];

try {
const payload = JSON.parse(atob(payloadString));
return payload.token;
} catch (error) {
throw new Error(
"Invalid clientKey attribute in DuffelCardForm. It was not possible to read the payload."
);
}
}
78 changes: 78 additions & 0 deletions src/components/DuffelCardForm/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { CustomStyles } from "src/types";

export interface CreateCardForTemporaryUseData {
id: string;
live_mode: false;
}

export interface CreateCardForTemporaryUseError {
status: number;
message: string;
}

export type DuffelCardFormStyles = Pick<CustomStyles, "fontFamily"> & {
stylesheetUrl?: string;
};

export type DuffelCardFormActions =
| "validate"
| "create-card-for-temporary-use";

export interface DuffelCardFormProps {
/**
* The client key present in the Quote object.
*/
clientKey: string;

/**
* The styles to apply to the iframe input elements.
*/
styles?: DuffelCardFormStyles;

/**
* If you want to develop with a local deployment of the token proxy on port 8000. Set this flag to true.
*/
shouldUseLocalTokenProxy?: boolean;

/**
* The actions you'd like the component to perform.
*
* This prop is a dependecy of a useEffect hook in the component
* and so when it's changed it will perform the action you specify.
*
* The action `create-card-for-temporary-use` will only happen once `validate` has been successful.
*
*/
actions: DuffelCardFormActions[];

/**
* This function will be called when the card form validation has been successful.
*/
onValidateSuccess: () => void;

/**
* If the card form validation is successful but data is changed afterwards,
* making it invalid, this function will be called.
*/
onValidateFailure: () => void;

/**
* This function will be called when the card has been created for temporary use.
*
* This callback will only be triggered if the `create-card-for-temporary-use`
* action is present in the `actions` prop.
*/
onCreateCardForTemporaryUseSuccess: (
data: CreateCardForTemporaryUseData
) => void;

/**
* This function will be called when the component has failed to create the card for temporary use.
*
* This callback will only be triggered if the `create-card-for-temporary-use`
* action is present in the `actions` prop.
*/
onCreateCardForTemporaryUseFailure: (
error: CreateCardForTemporaryUseError
) => void;
}
8 changes: 4 additions & 4 deletions src/components/DuffelPayments/DuffelPaymentsCustomElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ export function onDuffelPaymentsSuccessfulPayment(
onSuccessfulPayment: DuffelPaymentsProps["onSuccessfulPayment"]
) {
const element = tryToGetDuffelPaymentsCustomElement(
"onDuffelPaymentsPayloadReady"
"onDuffelPaymentsSuccessfulPayment"
);

// using `as EventListener` here because typescript doesn't know the event type for `onPayloadReady`
// There's a few different suggestions to resolve this seemed good enough
// You can learn more here: https://github.com/microsoft/TypeScript/issues/28357
element.addEventListener(
"onPayloadReady",
"onSuccessfulPayment",
onSuccessfulPayment as EventListener
);
}
Expand All @@ -117,7 +117,7 @@ export function onDuffelPaymentsFailedPayment(
onFailedPayment: DuffelPaymentsProps["onFailedPayment"]
) {
const element = tryToGetDuffelPaymentsCustomElement(
"onDuffelPaymentsPayloadReady"
"onDuffelPaymentsFailedPayment"
);
const eventListener = (event: OnFailedPaymentCustomEvent) => {
onFailedPayment(event.detail.error);
Expand All @@ -126,5 +126,5 @@ export function onDuffelPaymentsFailedPayment(
// using `as EventListener` here because typescript doesn't know the event type for `onPayloadReady`
// There's a few different suggestions to resolve this seemed good enough
// You can learn more here: https://github.com/microsoft/TypeScript/issues/28357
element.addEventListener("onPayloadReady", eventListener as EventListener);
element.addEventListener("onFailedPayment", eventListener as EventListener);
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
*/
export * from "./components/DuffelAncillaries/DuffelAncillaries";
export * from "./components/DuffelPayments/DuffelPayments";
export * from "./components/DuffelCardForm/DuffelCardForm";
export * from "./types";
33 changes: 33 additions & 0 deletions src/stories/DuffelCardForm.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DuffelCardForm } from "@components/DuffelCardForm/DuffelCardForm";
import { DuffelCardFormProps } from "@components/DuffelCardForm/lib/types";
import type { Meta, StoryObj } from "@storybook/react";

export default {
title: "DuffelCardForm",
component: DuffelCardForm,
} as Meta;

type DuffelCardFormStory = StoryObj<typeof DuffelCardForm>;

const defaultProps: DuffelCardFormProps = {
clientKey:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbiI6IkVOQ1JZUFRFRF9UT0tFTiJ9.qP-zHSkMn-O9VGSj4wkh_A4VDOIrzpgRxh1xgLZ51rk",
shouldUseLocalTokenProxy: true,
actions: ["validate"],
onValidateSuccess: console.log,
onValidateFailure: console.error,
onCreateCardForTemporaryUseSuccess: console.log,
onCreateCardForTemporaryUseFailure: console.error,
};

export const Default: DuffelCardFormStory = { args: defaultProps };

export const WithFont: DuffelCardFormStory = {
args: {
...defaultProps,
styles: {
fontFamily: `'Tangerine', serif`,
stylesheetUrl: "https://fonts.googleapis.com/css?family=Tangerine",
},
},
};
File renamed without changes.

0 comments on commit 57dcf94

Please sign in to comment.