Skip to content

Commit

Permalink
Add hosted-buttons component
Browse files Browse the repository at this point in the history
  • Loading branch information
jshawl committed Jan 16, 2024
1 parent 22d7f31 commit 174fd15
Show file tree
Hide file tree
Showing 12 changed files with 536 additions and 4 deletions.
4 changes: 4 additions & 0 deletions __sdk__.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,8 @@ module.exports = {
entry: "./src/interface/card-fields",
globals,
},
"hosted-buttons": {
entry: "./src/interface/hosted-buttons",
globals,
},
};
71 changes: 71 additions & 0 deletions src/hosted-buttons/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/* @flow */

import { getMerchantID } from "@paypal/sdk-client/src";

import { getButtonsComponent } from "../zoid/buttons";

import {
buildHostedButtonCreateOrder,
buildHostedButtonOnApprove,
getHostedButtonDetails,
renderForm,
} from "./utils";
import type {
HostedButtonsComponent,
HostedButtonsComponentProps,
HostedButtonsInstance,
} from "./types";

export const getHostedButtonsComponent = (): HostedButtonsComponent => {
function HostedButtons({
hostedButtonId,
}: HostedButtonsComponentProps): HostedButtonsInstance {
const Buttons = getButtonsComponent();
const render = (selector) => {
// The SDK supports mutiple merchant IDs, but hosted buttons only
// have one merchant id as a query parameter to the SDK script.
// https://github.com/paypal/paypal-sdk-client/blob/c58e35f8f7adbab76523eb25b9c10543449d2d29/src/script.js#L144
const merchantId = getMerchantID()[0];

getHostedButtonDetails({ hostedButtonId }).then(
({ html, htmlScript, style }) => {
renderForm({ html, htmlScript, selector });

// $FlowFixMe
Buttons({
style,
hostedButtonId,
onInit(data, actions) {
// disable the button, listen for input changes,
// and enable the button when the form is valid
// using actions.disable() and actions.enable()
window[`__pp_form_fields_${hostedButtonId}`]?.onInit?.(
data,
actions
);
},
onClick(data, actions) {
// render form errors, if present
window[`__pp_form_fields_${hostedButtonId}`]?.onClick?.(
data,
actions
);
},
createOrder: buildHostedButtonCreateOrder({
hostedButtonId,
merchantId,
}),
onApprove: buildHostedButtonOnApprove({
hostedButtonId,
merchantId,
}),
}).render(selector);
}
);
};
return {
render,
};
}
return HostedButtons;
};
84 changes: 84 additions & 0 deletions src/hosted-buttons/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/* @flow */

import { describe, test, expect, vi } from "vitest";
import { request } from "@krakenjs/belter/src";
import { ZalgoPromise } from "@krakenjs/zalgo-promise";

import { getButtonsComponent } from "../zoid/buttons";

import { getHostedButtonsComponent } from ".";

vi.mock("@krakenjs/belter/src", async () => {
return {
...(await vi.importActual("@krakenjs/belter/src")),
request: vi.fn(),
};
});

vi.mock("@paypal/sdk-client/src", async () => {
return {
...(await vi.importActual("@paypal/sdk-client/src")),
getSDKHost: () => "example.com",
getClientID: () => "client_id_123",
getMerchantID: () => ["merchant_id_123"],
};
});

vi.mock("../zoid/buttons", async () => {
return {
...(await vi.importActual("../zoid/buttons")),
getButtonsComponent: vi.fn(),
};
});

const getHostedButtonDetailsResponse = {
body: {
button_details: {
link_variables: [
{
name: "shape",
value: "rect",
},
{
name: "layout",
value: "vertical",
},
{
name: "color",
value: "gold",
},
{
name: "button_text",
value: "paypal",
},
{
name: "button_type",
value: "FIXED_PRICE",
},
],
},
},
};

describe("HostedButtons", () => {
test("paypal.Buttons calls getHostedButtonDetails and invokes v5 of the SDK", () => {
const Buttons = vi.fn(() => ({ render: vi.fn() }));
// $FlowIssue
getButtonsComponent.mockImplementationOnce(() => Buttons);
const HostedButtons = getHostedButtonsComponent();
// $FlowIssue
request.mockImplementationOnce(() =>
ZalgoPromise.resolve(getHostedButtonDetailsResponse)
);
HostedButtons({
hostedButtonId: "B1234567890",
}).render("#example");
expect(Buttons).toHaveBeenCalledWith(
expect.objectContaining({
hostedButtonId: "B1234567890",
hostedButtonType: "NO_CODE_FIXED_PRICE",
})
);
expect.assertions(1);
});
});
53 changes: 53 additions & 0 deletions src/hosted-buttons/types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* @flow */

import { ZalgoPromise } from "@krakenjs/zalgo-promise/src";

export type HostedButtonsComponentProps = {|
hostedButtonId: string,
|};

export type GetCallbackProps = {|
hostedButtonId: string,
merchantId: string,
|};

export type HostedButtonsInstance = {|
render: (string | HTMLElement) => void,
|};

export type HostedButtonDetailsParams =
(HostedButtonsComponentProps) => ZalgoPromise<{|
html: string,
htmlScript: string,
style: {|
layout: string,
shape: string,
color: string,
label: string,
|},
|}>;

export type ButtonVariables = $ReadOnlyArray<{|
name: string,
value: string,
|}>;

export type CreateOrder = (data: {|
paymentSource: string,
|}) => ZalgoPromise<string>;

export type OnApprove = (data: {|
orderID: string,
paymentSource: string,
|}) => ZalgoPromise<mixed>;

export type CreateAccessToken = (clientID: string) => ZalgoPromise<string>;

export type HostedButtonsComponent =
(HostedButtonsComponentProps) => HostedButtonsInstance;

export type RenderForm = {|
html: string,
htmlScript: string,
selector: string | HTMLElement,
|};
148 changes: 148 additions & 0 deletions src/hosted-buttons/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/* @flow */

import { request, memoize, popup } from "@krakenjs/belter/src";
import { getSDKHost, getClientID } from "@paypal/sdk-client/src";

import { DEFAULT_POPUP_SIZE } from "../zoid/checkout";

import type {
ButtonVariables,
CreateAccessToken,
CreateOrder,
GetCallbackProps,
HostedButtonDetailsParams,
OnApprove,
RenderForm,
} from "./types";

const entryPoint = "SDK";
const baseUrl = `https://${getSDKHost()}`;
const apiUrl = baseUrl.replace("www", "api");

const getHeaders = (accessToken?: string) => ({
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
"Content-Type": "application/json",
"PayPal-Entry-Point": entryPoint,
});

export const createAccessToken: CreateAccessToken = memoize<CreateAccessToken>(
(clientId) => {
return request({
url: `${apiUrl}/v1/oauth2/token`,
method: "POST",
body: "grant_type=client_credentials",
headers: {
Authorization: `Basic ${btoa(clientId)}`,
"Content-Type": "application/json",
},
}).then((response) => response.body.access_token);
}
);

const getButtonVariable = (variables: ButtonVariables, key: string): string =>
variables?.find((variable) => variable.name === key)?.value ?? "";

const getFundingSource = (paymentSource) => {
if (paymentSource === "credit") {
return `CARD`;
}
return paymentSource.toUpperCase();
};

export const getHostedButtonDetails: HostedButtonDetailsParams = ({
hostedButtonId,
}) => {
return request({
url: `${baseUrl}/ncp/api/form-fields/${hostedButtonId}`,
headers: getHeaders(),
}).then(({ body }) => {
const variables = body.button_details.link_variables;
return {
style: {
layout: getButtonVariable(variables, "layout"),
shape: getButtonVariable(variables, "shape"),
color: getButtonVariable(variables, "color"),
label: getButtonVariable(variables, "button_text"),
},
html: body.html,
htmlScript: body.html_script,
};
});
};

/**
* Attaches form fields (html) to the given selector, and
* initializes window.__pp_form_fields (htmlScript).
*/
export const renderForm = ({
html,
htmlScript,
selector,
}: RenderForm): void => {
const elm =
typeof selector === "string" ? document.querySelector(selector) : selector;
if (elm) {
elm.innerHTML = html + htmlScript;
const newScriptEl = document.createElement("script");
const oldScriptEl = elm.querySelector("script");
newScriptEl.innerHTML = oldScriptEl?.innerHTML ?? "";
oldScriptEl?.parentNode?.replaceChild(newScriptEl, oldScriptEl);
}
};

export const buildHostedButtonCreateOrder = ({
hostedButtonId,
merchantId,
}: GetCallbackProps): CreateOrder => {
return (data) => {
const userInputs =
window[`__pp_form_fields_${hostedButtonId}`]?.getUserInputs?.() || {};
return createAccessToken(getClientID()).then((accessToken) => {
return request({
url: `${apiUrl}/v1/checkout/links/${hostedButtonId}/create-context`,
headers: getHeaders(accessToken),
method: "POST",
body: JSON.stringify({
entry_point: entryPoint,
funding_source: getFundingSource(data.paymentSource),
merchant_id: merchantId,
...userInputs,
}),
}).then(({ body }) => {
return body.context_id;
});
});
};
};

export const buildHostedButtonOnApprove = ({
hostedButtonId,
merchantId,
}: GetCallbackProps): OnApprove => {
return (data) => {
return createAccessToken(getClientID()).then((accessToken) => {
return request({
url: `${apiUrl}/v1/checkout/links/${hostedButtonId}/pay`,
headers: getHeaders(accessToken),
method: "POST",
body: JSON.stringify({
entry_point: entryPoint,
merchant_id: merchantId,
context_id: data.orderID,
}),
}).then((response) => {
// The "Debit or Credit Card" button does not open a popup
// so we need to open a new popup for buyers who complete
// a checkout via "Debit or Credit Card".
if (data.paymentSource === "card") {
const url = `${baseUrl}/ncp/payment/${hostedButtonId}/${data.orderID}`;
popup(url, {
width: DEFAULT_POPUP_SIZE.WIDTH,
height: DEFAULT_POPUP_SIZE.HEIGHT,
});
}
return response;
});
});
};
};
Loading

0 comments on commit 174fd15

Please sign in to comment.