Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(payment): PAYPAL-4867 POC of headless wallet buttons #2742

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

bc-nick
Copy link
Contributor

@bc-nick bc-nick commented Nov 27, 2024

What?

POC of Headless Wallet Buttons

Why?

According to the purpose of the project

Testing / Proof

headless_demo_1.mov

@bigcommerce/team-checkout @bigcommerce/team-payments

{
"inputPath": "packages/*/src/index.ts",
"outputPath": "packages/core/src/generated/checkout-headless-button-strategies.ts",
"memberPattern": "^create.+HeadlessButtonStrategy$"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to think about the right pattern for headless strategies. test-core is failed because for button strategies we have the same substring (ButtonStrategy) as for headless button strategies in memberPattern field. For this reason, we do not have a unique token. Should be replaced with one of the following examples: HeadlessWalletStrategy, HeadlessStrategy or something like that

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use HeadlessWalletStrategy here.

const jwtToken = state.config.getStorefrontJwtToken();

if (!jwtToken) {
throw new MissingDataError(MissingDataErrorType.MissingPaymentToken);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be better to check if this error fits here, since StorefrontJWTToken is not equals to PaymentToken

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeap it is not difficult to switch it for another error. We can create a new one if we do not an appropriate

})
.then((response) => {
observer.next(
createAction(CartActionType.LoadCartSucceeded, response.body),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we update LoadCartSucceeded with LoadCartEntitySucceeded? So in this case we will 100% sure that this action type is belong to current request

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can change the name if we wish, if necessary


const mappedLineItems: LineItemMap = {
// @ts-ignore
physicalItems: lineItems.physicalItems.map((item) => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle digital items here as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, not only digital items. We need to parse response and map it into a data regarding to Cart interface. I plan to make it after aligning the general functionality with Checkout team.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks fine, but we need to take care of all kinds of items in future, such as custom item.

import createCheckoutButtonSelectors from './create-checkout-button-selectors';

@bind
export default class CheckoutHeadlessButtonInitializer {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HeadlessCheckoutButtonInitializer looks better to me, since CheckoutButton is a type of a button (like user may process checkout flow through the button..)

Suggested change
export default class CheckoutHeadlessButtonInitializer {
export default class HeadlessCheckoutButtonInitializer {

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will keep that in mind

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no registry v1 for headless button, so we can update file name with create-headless-checkout-button-registry.ts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will keep that in mind

@@ -1,4 +1,6 @@
export default interface CheckoutButtonInitializerOptions {
host?: string;
locale?: string;
storefrontJwtToken?: string;
siteLink?: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why siteLink should be created when we can use host instead of it? As far as I remember host is equal to site link

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

host is BC Control Panel
siteLink is the store url that we need to use making the right redirect on confirmation page

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For production, we may create a new interface for the headless wallet buttons.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean host is the canonical URL, i.e.: https://store-{hash}.mybigcommerce.com, and siteUrl is the BC Checkout's site URL (not the headless storefront site URL)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidchin yes, you are correct

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bc-peng do you mean create a file with the similar interface to this one and name it HeadlessWalletInitializerOptions?

Copy link
Contributor

@bc-peng bc-peng Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, keep the existing checkout button unchanged, create a new one like HeadlessWalletInitializerOptions.

}
}

private async onHostedCheckoutApprove(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method should be available only in Phase 2 of this project

this.onShippingOptionsChange(data),
};

const hostedCheckoutCallbacks = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be removed

Comment on lines 91 to 102
const onShippingChangeCallbacks = {
onShippingAddressChange: (data: ShippingAddressChangeCallbackPayload) =>
this.onShippingAddressChange(data),
onShippingOptionsChange: (data: ShippingOptionChangeCallbackPayload) =>
this.onShippingOptionsChange(data),
};

const hostedCheckoutCallbacks = {
...onShippingChangeCallbacks,
onApprove: (data: ApproveCallbackPayload, actions: ApproveCallbackActions) =>
this.onHostedCheckoutApprove(data, actions, methodId),
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part should be removed since it is outside of Headless WB Phase 1 scope


return new PayPalCommerceIntegrationService(
createFormPoster(),
createFormPoster({ host: getSiteLink() }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can getHost be used here instead of getSiteLink?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[key: string]: CheckoutButtonStrategyFactory<CheckoutButtonStrategy>;
}

export default function createCheckoutHeadlessButtonStrategyRegistry(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function looks like a duplicate of createCheckoutButtonStrategyRegistry, except that the default value for checkoutButtonHeadlessStrategyFactories parameter is different. Instead of duplicating, could we just call createCheckoutButtonStrategyRegistry and pass it with defaultCheckoutHeadlessButtonStrategyFactories instead? It should work for both headless and non-headless because both implement the same interface.

{
"inputPath": "packages/*/src/index.ts",
"outputPath": "packages/core/src/generated/checkout-headless-button-strategies.ts",
"memberPattern": "^create.+HeadlessButtonStrategy$"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can use HeadlessWalletStrategy here.

...response,
body: {
id: entityId,
// @ts-ignore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please explain why do we have @ts-ignore here?

constructor(private _cartRequestSender: CartRequestSender) {}

@cachableAction
loadCardEntity(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is loadCardEntity the correct name? Or loadCart?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will switch to your version. Thanks


const mappedLineItems: LineItemMap = {
// @ts-ignore
physicalItems: lineItems.physicalItems.map((item) => ({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks fine, but we need to take care of all kinds of items in future, such as custom item.

@@ -1,4 +1,6 @@
export default interface CheckoutButtonInitializerOptions {
host?: string;
locale?: string;
storefrontJwtToken?: string;
siteLink?: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For production, we may create a new interface for the headless wallet buttons.

* @param options - Options for initializing the checkout button.
* @returns A promise that resolves to the current state.
*/
initializeHeadlessButton(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why we don't call this method, intialize?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean we do not call this method?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In other initializers, we refer to this method as initialize. I was wondering if there is a specific reason we didn't follow this pattern.

@@ -204,6 +205,17 @@ export default class DefaultPaymentIntegrationService implements PaymentIntegrat
return buyNowCart;
}

async loadCardEntity(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
async loadCardEntity(
async loadCart(

},
};

return this._requestSender
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me that we're making a cross-origin Ajax request from headless storefront to BC, which then returns a session token and set as a third party cookie. AFAIK, Safari blocks third party cookies. Could you test if the setup works for Safari?

Another point is, Catalyst creates carts on the server-side. Therefore, the browser does not have BC session cookie required to retrieve carts directly from the browser from BC. Could you also test if the setup works for Catalyst?

I believe the solution to both of these problems is to proxy GQL calls through Catalyst or a server-side middleware for non-Catalyst headless storefronts.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidchin we were aware about these kind of specific behaviour related cart creation process.
If cart was created by server-side we are not able to get any information by cart_id from browser, therefore customer have to make a cart creation request from browser in order to receive cookies. This point will be mentioned in documentation of headless wallet button.

For Safari's case we can avoid blocking of third party cookies by enabling/disabling the appropriate settings.
But Safari blocking it by default, we should think about a workaround

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Safari's case we can avoid blocking of third party cookies by enabling/disabling the appropriate settings.

Yes, but this is not a setting we have control over, as we can't force shoppers to relax their privacy setting. I think this issue is a blocker. If we can't make it work for Safari by default, we need to explore alternative options.

This point will be mentioned in documentation of headless wallet button.

I don't think documentation is sufficient as the solution won't be compatible with headless Catalyst, which creates carts on the server side.

It seems to me that to solve both of these problems, we shouldn't be calling the GQL API directly in the browser for headless storefronts. Instead, requests need to be routed through a server-side proxy, which in most cases will be the headless storefronts.

.then(this.transformToCartResponse);
}

private transformToCartResponse(response: Response<LoadCartResponse>): Response<Cart> {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidchin @animesh1987 @bc-peng guys I would like to ask you to take a loot at this transformer much closer there are concerns I have encountered.

This transformer is going to be very large because here i am trying to transform response in an appropriate Cart interface to align with the current realisation of stored cart information.

There are a few problems from the current approach:

  1. it is hard to support/maintain this code in the future
  2. we do not have all required Cart fields information for properly transforming
  3. double dependency with Cart interface

I would like to suggest creating a new Headless Cart reducer for avoiding bindings to Cart interface and all things above. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for bringing this up, my suggestion is that we should not have this in the reducer since that's not the purpose of a reducer. I see this is to transform the response from the loadCart API to a cart here in request sender.

Maybe we can have something similar to this and all transform logic can be placed there.

Maybe you can also think of moving the transform method in the action creator and not in the request sender.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@animesh1987 Unfortunately we do not have all required information from response for properly transformation to Cart. For example we are not able to get coupons, customerId, email from GraphQL request but these fields are required in Cart. Can we just set default parameters for them in this case for properly transformation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@animesh1987 Why I suggest creating a new reducer is because we can more easily manage this information. This reducer will only take the necessary information for Headless Wallet Buttons strategies

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @bc-nick what is the GQL endpoint we are calling here? Maybe we can call checkout GQL endpoint for the information you need once you have a cart.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants