vuestorefront / vue-storefront

Alokai is a Frontend as a Service solution that simplifies composable commerce. It connects all the technologies needed to build and deploy fast & scalable ecommerce frontends. It guides merchants to deliver exceptional customer experiences quickly and easily.
https://www.alokai.com
MIT License
10.64k stars 2.08k forks source link

[SPIKE] Discuss Checkout Composable #4576

Closed filrak closed 4 years ago

filrak commented 4 years ago

What is the motivation for adding / enhancing this feature?

Currently there is no standardized way to create checkout composable and that's the last missing piece of them. Even though for most of the platforms integrated with VSF Next we are using external checkout as a recommended way there are platforms like commercetools that are purely API-driven and doesn't have this option

Most likely we will need to make this in the same way we made useuser composable - split it by smaller entities correlating to specific feature/subpage,

In that case we will (probably) need an interface for

What are the acceptance criteria

TODO After finishing

andrzejewsky commented 4 years ago

First of all, we have to distinguish two things: checkout itself and payment. The checkout is actually a part of eCommerce platform, without that you can't make an order. Is there any platform that doesn't have a checkout? The only thing we delegate externally is a payment system or more (eg. delivery).

Let's start with defining composables and challenge them. I propose the following ones:

Now, below a few potential issues/concerns that needs to be solved:

To sum up. Checkout is essentially the most difficult part to unify. It has a huge variety of possible implementations. I'm even thinking about preparing totally custom composables per eCommerce integration with the implementation of its own checkout and exactly the same with payment systems.

filrak commented 4 years ago

@andrzejewsky this is looking very good - I see that similarly.

For the shared state - I think there is nothing wrong with sharing the state between checkout compostable similarly to what we have with useUser. In the end they're just kind of "subcomposables" and the connection is implict. Also some thoughts about your concerns:

billing and shipping are very similar and can be combined into the one composable eg. useCheckoutAddress, they are just taking data from the form and saving it to the API, perhaps it will be something more complex further?

We could do that. The only thing here that matters to me is extensibility. We have to build it in a way that allows to easily integrate any 3rd-party shipping provider.

payment is the most complicated part, most of the payment systems provides also special secured UI that uses iframes to load inputs, so you can skip that part - what about this? Is it mean that along with composable we need to provide a component as well? Or we'll provide just a composable, but the component is the developer's responsibility? - think this is the most reasonable payment hooks - some of the payment systems require backend (eg. braintree) eg. to validate something, do we provide this as well? nuxt (at least as basic one)? confirmations - sometimes an external payment system redirects us somewhere to our app, with some kind of confirmation eg. when payment succeeded. I think with payment integration we should provide also an example of error/success pages that can be replaced by the developer.

Yes, I thought about ti this way: each payment integration needs to fulfill some specific contract (let it be a typescript interface) so it can be passed to the setup config and extend proper parts of the app (hooks as you called them)

Apart from that there should always be a component that will have to be used within VSF App in a checkout that has to be added manually (in the future we can probably add it automatically as well)

we have one composable for payment and billing details, of course it makes sense as it's a related thing, but what if you need to save billing address to the eCommerce platform but payment is managed by something external (that's a real example in CT)?

I don't see how having both as one composable will prevent what you've written about

filrak commented 4 years ago

DRAFT, DISCUSSION WILL BE CONTINUED

Discussion outcome:

  1. We will use builder pattern to dynamically generate checkout forms based on provided JSON (https://www.youtube.com/watch?v=AEW7NSF-YqY)
  2. Each composable will fetch the fields which will be then transformed into the default data model used to generate forms. 2 .Data model for each composable can be easily configured via configureFormDataModel function that accepts the key property which is a unique id of a data model to configure (we assume that we can use this pattern for all sophisticated forms in the app) and a configure function that enables to change the data model.
    configureFormDataModel(key: string, configure: (currentDataModel: FormDataModel) => newDataModel: FormDataModel);
    formDataModels: Object;
    
    import { configureFormDataModel, formDataModels } from '@vue-storefront/commercetools`

configureFormDataModel(formDataModels.checkout.customer.id, async currentDataModel => { ...currentDataModel, firstName: { type: 'input', validation: ['required'] }, isCompany: { type: 'radio', validation: ['required'], options: ['yes', 'no'] }, country: { type: 'select', options: currentDataModel.countries.options } // we can map some properties values to others })

3. What if we need to load some data lazily? For example if we select a country we have to load it's regions lazily. In that case we can use `onChange` method available for each form field to fetch additional data:
```js
configureFormDataModel(formDataModels.checkout.customer.id, async currentDataModel => {
  ...currentDataModel,
  country: { type: 'select', options: currentDataModel.countries.options, onChange: async (value) => {
    currentDataModel.regions = getRegionsFromApi(value.id)
  }
})

Payment Plugins

This part has to be further discussed, especially overall extensibility. For that part we will focus only on payments.

Vue Storefront Next composable should expose extension points (hooks) that can be accessed through a plugin.Extension points will be the same for every integration.

When we want to register a plugin we should use addPlugin function.

For example we could do this to register PayPal extension

// you specify which type of plugin you're registering
import { addPlugin } from '@vue-storefront/commercetools`
import { PayPalPlugin } from 'paypal-extension'

// registration
addPlugin(PayPalPlugin)

Inside the plugin we will use addPaymentMethod hook to register new payment method

// inside payment plugin
export PayPalPlugin = {
  addPaymentMethod() {
   return { ... }
 }
}
andrzejewsky commented 4 years ago

Discuss notes (interfaces draft):


interface FormRow  {}

interface AgnosticForm {
  [field: string]: FormRow
}

interface UseCheckoutCustomer <CUSTOMER, LOAD_PARAMS, SAVE_PARAMS> {
  async loadCheckoutCustomer: (params: LOAD_PARAMS) => void;
  async saveCheckoutCustomer: (params: SAVE_PARAMS) => void;
  checkoutCustomer: ComputedProperty<CUSTOMER>;
  form: AgnosticForm;
  loading: boolean;
  error: any;
}

interface UseCheckoutShipping <SHIPPING, LOAD_PARAMS, SAVE_PARAMS> {
  async loadCheckoutShipping: (params: LOAD_PARAMS) => void;
  async saveCheckoutShipping: (params: SAVE_PARAMS) => void;
  checkoutShipping: ComputedProperty<SHIPPING>;
  form: AgnosticForm;
  loading: boolean;
  error: any;
}

interface UseCheckoutPayment <PAYMENT, LOAD_PARAMS, SAVE_PARAMS> {
  async loadCheckoutPayment: (params: LOAD_PARAMS) => void;
  async saveCheckoutPayment: (params: SAVE_PARAMS) => void;
  checkoutPayment: ComputedProperty<PAYMENT>;
  form: AgnosticForm;
  loading: boolean;
  error: any;
}

interface UseCheckoutOrder <ORDER, LOAD_PARAMS, SAVE_PARAMS> {
  async loadCheckoutOrder: (params: LOAD_PARAMS) => void;
  async saveCheckoutOrder: (params: SAVE_PARAMS) => void;
  checkoutOrder: ComputedProperty<ORDER>;
  form: AgnosticForm;
  loading: boolean;
  error: any;
}

const useCheckoutCustomer = (): UseCheckoutCustomer => {}
const useCheckoutShipping = (): UseCheckoutShipping => {}
const useCheckoutPayment = (): UseCheckoutPayment => {}
const useCheckoutOrder = (): UseCheckoutOrder => {}

Loading and saving data

Each composable are very similar to each other. They have functions to load the data (loadXXX) and saving them (saveXXX). Normally both load and saving functions are loading everything you need to work on the data, but sometimes there is a need to save or load something independently - for that purpose, we'd like to introduce params for these functions:

const { loadCheckoutPayment } = useCheckoutShipping()

loadCheckoutPayment() // loads everything
loadCheckoutPayment({ shippingData: true }) // loads only shipping data
loadCheckoutPayment({ shippingMethids: tue }) // loads only shipping methods

Form building and extending

Each composable has a form field that contains the only data we need to display on the form. There always a set of default form model (specific for the platform), but is extendable as well by using a special mechanism (extend function). Furthermore, a form field will be used by our form builder directly to render the given fields. The form fields are being built by using a response from the API

example of a call

const { form, loadCheckoutShipping, saveCheckoutShipping } = useCheckoutShipping()

loadCheckoutShipping() // loads: { firsrName, lastName, totals, price }
// form contains: { firstName, lastName } - only these fields should be in the form

<template>
 <FormBuilder :form="form" @save="saveCheckoutShipping" />
<template>

example of extending


extend('checkout/shipping', async (currentFormModel) => ({
  ...currentFormModel, // it contains firstName and lastName
 saveForLater: { type: 'input', validation: ['required'] } // we are adding a new field
}))

const { form, loadCheckoutShipping, saveCheckoutShipping } = useCheckoutShipping()

loadCheckoutShipping() // loads: { firsrName, lastName, totals, price }
// form contains: { firstName, lastName, saveForLater } - new field appears here

<template>
 <FormBuilder :form="form" @save="saveCheckoutShipping" />
<template>

External payment

Sometimes we need to integrate with the external payment gateway, which in most cases comes with extending the form as well. In order to do this, again we can use our extending mechanism (similar to the previous example) for adding the payment extension:

extend('checkout/addPaymentMethod', async () => ({ ... })) // adds the  new payment method

const { form, saveCheckoutPayment } = useCheckoutPayment();

// form - new payment method is in  the  form now

saveCheckoutPayment() // calling this we are selecting payment methond along with billing data

Integration part, an example of implmementation

In order to better understanding, here is an example of implementation some of the compostables:

Example of Shipping

const useCheckoutShipping = () => {
  const createForm = provide('checkout/extendShippingForm') // that comes from extending mechanism
  const checkoutShipping = ref({});
  const form =  ref({});
  const loading = ref(false);
  const error = ref(null);

  const  loadCheckoutShipping =  async (params) => {
    const resp = await someApiCall();
    checkoutShipping.value = resp.data.shipping;
    form.value = createForm(resp.data);
  }
  const  saveCheckoutShipping = async (formData) => {
    const resp = await someApiCall(formData);
    checkoutShipping.value = resp.data.shipping;
    form.value = createForm(resp.data);
  };

  return {
    loadCheckoutShipping,
    saveCheckoutShipping,
    form,
    loading,
    error
  };
}

Example of payments

const useCheckoutPayment = () => {
  const createForm = provide('checkout/extendPaymentForm') // that comes from extending mechanism
  const createNewPaymentMethod = provide('checkout/addNewPaymentMethod') // that comes from extending mechanism
  const checkoutPayment= ref({});
  const form =  ref({});
  const loading = ref(false);
  const error = ref(null);

  const  loadCheckoutPayment =  async (params) => {
    const resp = await someApiCall();
    const newPaymentMethod = await createNewPaymentMethod(resp); // we are adding a new  payment method
    checkoutPayment.value = resp.data.payment;
    checkoutPayment.value.paymentMethods.push(newPaymentMethod);
    form.value = createForm(resp.data);
  }
  const  saveCheckoutPayment = async (formData) => {
    const resp = await someApiCall(formData);
    checkoutPayment.value = resp.data.payment;
    form.value = createForm(resp.data);
  };

  return {
    loadCheckoutPayment,
    saveCheckoutPayment,
    form,
    loading,
    error
  };
}
filrak commented 4 years ago

Regarding External Payments - we will most likely pass an object with a unified payment interface rather than a function, apart from that I think we are close to writing actual code

filrak commented 4 years ago

The interface will be implemented as described, the extendibility mechanism discussions will be continued in the next issue.

Some examples of usage:

interface UseCheckoutCustomer <CUSTOMER, LOAD_PARAMS, SAVE_PARAMS> {
  async loadCheckoutCustomer: (params: LOAD_PARAMS) => void;
  async saveCheckoutCustomer: (params: SAVE_PARAMS) => void;
  checkoutCustomer: ComputedProperty<CUSTOMER>;
  form: AgnosticForm;
  loading: boolean;
  error: any;
}

More examples with current API

Factory params (integration)