Skip to main content
All Examples

Statechart Checkout

Checkout workflow powered by the experimental statechart module. Guards skip Shipping for digital orders, gate Place order behind a complete review, and parse promo codes into applied discounts.

Statecharts
Commands
Experimental
/
import {
  Array,
  Duration,
  Effect,
  Match as M,
  Option,
  Schema as S,
  String,
  pipe,
} from 'effect'
import { Command, Runtime } from 'foldkit'
import {
  type TransitionResult,
  defineMachine,
  otherwise,
  to,
  when,
} from 'foldkit/experimental/statechart'
import { m } from 'foldkit/message'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'

// MODEL

export const Discount = S.Struct({
  code: S.String,
  percentOff: S.Number,
})

export const NoPromo = ts('NoPromo')
export const AppliedPromo = ts('AppliedPromo', { discount: Discount })
export const RejectedPromo = ts('RejectedPromo')

export const Promo = S.Union([NoPromo, AppliedPromo, RejectedPromo])

export const Cart = ts('Cart', { isShippingRequired: S.Boolean })
export const Shipping = ts('Shipping', { isShippingRequired: S.Boolean })
export const Payment = ts('Payment', {
  isPaymentMethodSelected: S.Boolean,
  isShippingRequired: S.Boolean,
})
export const Review = ts('Review', {
  isPaymentMethodSelected: S.Boolean,
  isShippingRequired: S.Boolean,
  isTermsAccepted: S.Boolean,
  promo: Promo,
  promoCodeInput: S.String,
})
export const Placing = ts('Placing', {
  isShippingRequired: S.Boolean,
  maybeDiscount: S.Option(Discount),
})
export const Confirmed = ts('Confirmed', {
  isShippingRequired: S.Boolean,
  maybeDiscount: S.Option(Discount),
  orderId: S.String,
})
export const Cancelled = ts('Cancelled', {
  isShippingRequired: S.Boolean,
})
export const CheckoutState = S.Union([
  Cart,
  Shipping,
  Payment,
  Review,
  Placing,
  Confirmed,
  Cancelled,
])

export const Model = S.Struct({
  checkout: CheckoutState,
  maybeLastTransitionSummary: S.Option(S.String),
})
export type Model = typeof Model.Type

// MESSAGE

export const ClickedContinue = m('ClickedContinue')
export const ClickedBack = m('ClickedBack')
export const ClickedCancel = m('ClickedCancel')
export const ClickedPlaceOrder = m('ClickedPlaceOrder')
export const ClickedStartOver = m('ClickedStartOver')
export const ToggledPaymentMethod = m('ToggledPaymentMethod', {
  isSelected: S.Boolean,
})
export const SelectedEdition = m('SelectedEdition', {
  isShippingRequired: S.Boolean,
})
export const ToggledTermsAccepted = m('ToggledTermsAccepted', {
  isAccepted: S.Boolean,
})
export const UpdatedPromoCode = m('UpdatedPromoCode', { value: S.String })
export const SubmittedPromoCode = m('SubmittedPromoCode')
export const SucceededPlaceOrder = m('SucceededPlaceOrder', {
  orderId: S.String,
})

export const Message = S.Union([
  ClickedContinue,
  ClickedBack,
  ClickedCancel,
  ClickedPlaceOrder,
  ClickedStartOver,
  ToggledPaymentMethod,
  SelectedEdition,
  ToggledTermsAccepted,
  UpdatedPromoCode,
  SubmittedPromoCode,
  SucceededPlaceOrder,
])
export type Message = typeof Message.Type

// COMMAND

const PLACE_ORDER_DELAY = Duration.seconds(1)

export const PlaceOrder = Command.define(
  'PlaceOrder',
  { isShippingRequired: S.Boolean },
  SucceededPlaceOrder,
)(({ isShippingRequired }) =>
  Effect.gen(function* () {
    yield* Effect.sleep(PLACE_ORDER_DELAY)
    return SucceededPlaceOrder({
      orderId: isShippingRequired ? 'SHIP-1001' : 'DIGI-1001',
    })
  }),
)

// MACHINE

const PROMO_DISCOUNTS: ReadonlyArray<typeof Discount.Type> = [
  { code: 'READER10', percentOff: 10 },
  { code: 'SIGNAL20', percentOff: 20 },
]

export const promoToMaybeDiscount = (
  promo: typeof Promo.Type,
): Option.Option<typeof Discount.Type> =>
  M.value(promo).pipe(
    M.tags({ AppliedPromo: appliedPromo => appliedPromo.discount }),
    M.option,
  )

export const isReviewReady = (review: typeof Review.Type): boolean =>
  review.isPaymentMethodSelected && review.isTermsAccepted

export const reviewToMaybeDiscount = (
  review: typeof Review.Type,
): Option.Option<typeof Discount.Type> => {
  const normalizedCode = pipe(
    review.promoCodeInput,
    String.trim,
    String.toUpperCase,
  )

  return Array.findFirst(
    PROMO_DISCOUNTS,
    discount => discount.code === normalizedCode,
  )
}

export const checkoutMachine = defineMachine({
  state: CheckoutState,
  message: Message,
})({
  initial: Cart({ isShippingRequired: true }),
  states: {
    Cart: {
      on: {
        SelectedEdition: to('Cart', ({ state, message }) =>
          evo(state, { isShippingRequired: () => message.isShippingRequired }),
        ),
        ClickedContinue: [
          when(
            state => state.isShippingRequired,
            'Shipping',
            ({ state }) =>
              Shipping({ isShippingRequired: state.isShippingRequired }),
          ),
          otherwise(
            to('Payment', ({ state }) =>
              Payment({
                isPaymentMethodSelected: false,
                isShippingRequired: state.isShippingRequired,
              }),
            ),
          ),
        ],
        ClickedCancel: to('Cancelled', ({ state }) =>
          Cancelled({ isShippingRequired: state.isShippingRequired }),
        ),
      },
    },
    Shipping: {
      on: {
        ClickedContinue: to('Payment', ({ state }) =>
          Payment({
            isPaymentMethodSelected: false,
            isShippingRequired: state.isShippingRequired,
          }),
        ),
        ClickedBack: to('Cart', ({ state }) =>
          Cart({ isShippingRequired: state.isShippingRequired }),
        ),
        ClickedCancel: to('Cancelled', ({ state }) =>
          Cancelled({ isShippingRequired: state.isShippingRequired }),
        ),
      },
    },
    Payment: {
      on: {
        ToggledPaymentMethod: to('Payment', ({ state, message }) =>
          evo(state, { isPaymentMethodSelected: () => message.isSelected }),
        ),
        ClickedContinue: to('Review', ({ state }) =>
          Review({
            isPaymentMethodSelected: state.isPaymentMethodSelected,
            isShippingRequired: state.isShippingRequired,
            isTermsAccepted: false,
            promo: NoPromo(),
            promoCodeInput: '',
          }),
        ),
        ClickedBack: [
          when(
            state => state.isShippingRequired,
            'Shipping',
            ({ state }) =>
              Shipping({ isShippingRequired: state.isShippingRequired }),
          ),
          otherwise(
            to('Cart', ({ state }) =>
              Cart({ isShippingRequired: state.isShippingRequired }),
            ),
          ),
        ],
        ClickedCancel: to('Cancelled', ({ state }) =>
          Cancelled({ isShippingRequired: state.isShippingRequired }),
        ),
      },
    },
    Review: {
      on: {
        ToggledPaymentMethod: to('Review', ({ state, message }) =>
          evo(state, { isPaymentMethodSelected: () => message.isSelected }),
        ),
        ToggledTermsAccepted: to('Review', ({ state, message }) =>
          evo(state, { isTermsAccepted: () => message.isAccepted }),
        ),
        UpdatedPromoCode: to('Review', ({ state, message }) =>
          evo(state, {
            promoCodeInput: () => message.value,
            promo: currentPromo =>
              currentPromo._tag === 'RejectedPromo' ? NoPromo() : currentPromo,
          }),
        ),
        SubmittedPromoCode: [
          when(
            reviewToMaybeDiscount,
            'Review',
            ({ state, guardValue: discount }) =>
              evo(state, { promo: () => AppliedPromo({ discount }) }),
          ),
          otherwise(
            to('Review', ({ state }) =>
              evo(state, { promo: () => RejectedPromo() }),
            ),
          ),
        ],
        ClickedPlaceOrder: [
          when(
            isReviewReady,
            'Placing',
            ({ state }) =>
              Placing({
                isShippingRequired: state.isShippingRequired,
                maybeDiscount: promoToMaybeDiscount(state.promo),
              }),
            ({ state }) => [
              PlaceOrder({ isShippingRequired: state.isShippingRequired }),
            ],
          ),
        ],
        ClickedBack: to('Payment', ({ state }) =>
          Payment({
            isPaymentMethodSelected: state.isPaymentMethodSelected,
            isShippingRequired: state.isShippingRequired,
          }),
        ),
        ClickedCancel: to('Cancelled', ({ state }) =>
          Cancelled({ isShippingRequired: state.isShippingRequired }),
        ),
      },
    },
    Placing: {
      on: {
        SucceededPlaceOrder: to('Confirmed', ({ state, message }) =>
          Confirmed({
            isShippingRequired: state.isShippingRequired,
            maybeDiscount: state.maybeDiscount,
            orderId: message.orderId,
          }),
        ),
      },
    },
    Confirmed: {
      on: {
        ClickedStartOver: to('Cart', ({ state }) =>
          Cart({ isShippingRequired: state.isShippingRequired }),
        ),
      },
    },
    Cancelled: {
      on: {
        ClickedStartOver: to('Cart', ({ state }) =>
          Cart({ isShippingRequired: state.isShippingRequired }),
        ),
      },
    },
  },
})

// INIT

export const initialModel: Model = {
  checkout: checkoutMachine.initial,
  maybeLastTransitionSummary: Option.none(),
}

export const init: Runtime.ApplicationInit<Model, Message> = () => [
  initialModel,
  [],
]

// UPDATE

type UpdateReturn = readonly [Model, ReadonlyArray<Command.Command<Message>>]

const resultToTransitionSummary = (
  result: TransitionResult<typeof CheckoutState.Type, Message>,
): string =>
  M.value(result).pipe(
    M.tagsExhaustive({
      Transitioned: ({ from, messageTag, target }) =>
        `${from} -> ${target} on ${messageTag}`,
      Ignored: ({ messageTag, stateTag }) =>
        `${messageTag} ignored in ${stateTag}`,
    }),
  )

export const update = (model: Model, message: Message): UpdateReturn => {
  const result = checkoutMachine.step(model.checkout, message)

  const { state: nextCheckout } = result

  const transitionCommands = M.value(result).pipe(
    M.tagsExhaustive({
      Transitioned: ({ commands }) => commands,
      Ignored: () => [],
    }),
  )

  const nextMaybeLastTransitionSummary = Option.some(
    resultToTransitionSummary(result),
  )

  return [
    evo(model, {
      checkout: () => nextCheckout,
      maybeLastTransitionSummary: () => nextMaybeLastTransitionSummary,
    }),
    transitionCommands,
  ]
}