Skip to main content
All Examples

Shopping Cart

An e-commerce application with a product listing, cart management, and checkout flow.

Routing
/
import { Effect, Match, Option, Schema } from 'effect'
import { Command, Runtime, Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { UrlRequest, load, pushUrl } from 'foldkit/navigation'
import { evo } from 'foldkit/struct'
import { Url, toString as urlToString } from 'foldkit/url'

import { products } from './data/products'
import { Cart } from './domain'
import { Cart as CartPage, Checkout, Products } from './page'
import {
  AppRoute,
  cartRouter,
  checkoutRouter,
  productsRouter,
  urlToAppRoute,
} from './route'

// MODEL

export const Model = Schema.Struct({
  route: AppRoute,
  cart: Cart.Cart,
  deliveryInstructions: Schema.String,
  orderPlaced: Schema.Boolean,
  productsPage: Products.Model,
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  CompletedNavigateInternal: {},
  CompletedLoadExternal: {},
  ClickedLink: { request: UrlRequest },
  ChangedUrl: { url: Url },
  GotProductsMessage: { message: Products.Message },
  ClickedIncrementQuantity: { itemId: Schema.String },
  ClickedDecrementQuantity: { itemId: Schema.String },
  ClickedRemoveCartItem: { itemId: Schema.String },
  ClickedClearCart: {},
  UpdatedDeliveryInstructions: { value: Schema.String },
  ClickedPlaceOrder: {},
})

export type Message = typeof Message.Type

// INIT

export const init: Runtime.RoutingApplicationInit<Model, Message> = (
  url: Url,
) => {
  return {
    model: {
      route: urlToAppRoute(url),
      cart: [],
      deliveryInstructions: '',
      orderPlaced: false,
      productsPage: Products.init(products),
    },
  }
}

// COMMAND

const NavigateInternal = Command.define('NavigateInternal', {
  args: { url: Schema.String },
  messages: [Message.CompletedNavigateInternal],
  execute: ({ url }) =>
    pushUrl(url).pipe(Effect.as(Message.CompletedNavigateInternal())),
})

const LoadExternal = Command.define('LoadExternal', {
  args: { href: Schema.String },
  messages: [Message.CompletedLoadExternal],
  execute: ({ href }) =>
    load(href).pipe(Effect.as(Message.CompletedLoadExternal())),
})

// UPDATE

type UpdateReturn = Update.Return<Model, Message>

const foldProductsOutMessage = Products.OutMessage.match<
  Update.Step<Model, Message>
>({
  AddedToCart:
    ({ item }) =>
    model => ({ model: evo(model, { cart: Cart.addItem(item) }) }),
  IncrementedQuantity:
    ({ itemId }) =>
    model => ({
      model: evo(model, { cart: Cart.incrementQuantity(itemId) }),
    }),
  DecrementedQuantity:
    ({ itemId }) =>
    model => ({
      model: evo(model, { cart: Cart.decrementQuantity(itemId) }),
    }),
})

const foldProducts = Update.foldChild({
  update: Products.update,
  read: (model: Model) => Option.some(model.productsPage),
  write: (model, nextProductsPage) =>
    evo(model, { productsPage: () => nextProductsPage }),
  toParentMessage: message => Message.GotProductsMessage({ message }),
  foldOutMessage: foldProductsOutMessage,
})

export const update = (model: Model, message: Message) =>
  Message.match<UpdateReturn>(message, {
    CompletedNavigateInternal: () => ({ model }),
    CompletedLoadExternal: () => ({ model }),

    ClickedLink: ({ request }) =>
      UrlRequest.match<UpdateReturn>(request, {
        Internal: ({ url }) => ({
          model,
          commands: [NavigateInternal({ url: urlToString(url) })],
        }),

        External: ({ href }) => ({
          model,
          commands: [LoadExternal({ href })],
        }),
      }),

    ChangedUrl: ({ url }) => ({
      model: evo(model, {
        route: () => urlToAppRoute(url),
      }),
    }),

    GotProductsMessage: ({ message }) => foldProducts(model, message),

    ClickedIncrementQuantity: ({ itemId }) => ({
      model: evo(model, {
        cart: Cart.incrementQuantity(itemId),
      }),
    }),

    ClickedDecrementQuantity: ({ itemId }) => ({
      model: evo(model, {
        cart: Cart.decrementQuantity(itemId),
      }),
    }),

    ClickedRemoveCartItem: ({ itemId }) => ({
      model: evo(model, {
        cart: Cart.removeItem(itemId),
      }),
    }),

    ClickedClearCart: () => ({
      model: evo(model, {
        cart: () => [],
      }),
    }),

    UpdatedDeliveryInstructions: ({ value }) => ({
      model: evo(model, {
        deliveryInstructions: () => value,
      }),
    }),

    ClickedPlaceOrder: () => ({
      model: evo(model, {
        orderPlaced: () => true,
        cart: () => [],
        deliveryInstructions: () => '',
      }),
    }),
  })

// VIEW

const navigationView = (
  currentRoute: AppRoute,
  cartCount: number,
  h: HtmlBuilder<Message>,
): Html => {
  const navLinkClassName = (isActive: boolean) =>
    `hover:bg-blue-600 font-medium px-3 py-1 rounded transition ${isActive ? 'bg-blue-700 bg-opacity-50' : ''}`

  return h.nav(
    [h.Class('bg-blue-500 text-white p-4 mb-6')],
    [
      h.ul(
        [h.Class('max-w-6xl mx-auto flex gap-6 justify-center list-none')],
        [
          h.li(
            [],
            [
              h.a(
                [
                  h.Href(productsRouter({ searchText: Option.none() })),
                  h.Class(navLinkClassName(currentRoute._tag === 'Products')),
                ],
                ['Products'],
              ),
            ],
          ),
          h.li(
            [],
            [
              h.a(
                [
                  h.Href(cartRouter()),
                  h.Class(navLinkClassName(currentRoute._tag === 'Cart')),
                ],
                cartCount > 0 ? [`Cart (${cartCount})`] : ['Cart'],
              ),
            ],
          ),
          h.li(
            [],
            [
              h.a(
                [
                  h.Href(checkoutRouter()),
                  h.Class(navLinkClassName(currentRoute._tag === 'Checkout')),
                ],
                ['Checkout'],
              ),
            ],
          ),
        ],
      ),
    ],
  )
}

const productsView = (model: Model, h: HtmlBuilder<Message>): Html =>
  h.submodel({
    slotId: 'products',
    model: model.productsPage,
    view: Products.view,
    viewInputs: { cart: model.cart },
    toParentMessage: message => Message.GotProductsMessage({ message }),
  })

const cartView = (model: Model, h: HtmlBuilder<Message>): Html =>
  CartPage.view(model.cart, h)

const checkoutView = (model: Model, h: HtmlBuilder<Message>): Html =>
  Checkout.view(model.cart, model.deliveryInstructions, model.orderPlaced, h)

const notFoundView = (path: string, h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Class('max-w-4xl mx-auto px-4 text-center')],
    [
      h.h1(
        [h.Class('text-4xl font-bold text-red-600 mb-6')],
        ['404 - Page Not Found'],
      ),
      h.p(
        [h.Class('text-lg text-gray-600 mb-4')],
        [`The path "${path}" was not found.`],
      ),
      h.a(
        [
          h.Href(productsRouter({ searchText: Option.none() })),
          h.Class('text-blue-500 hover:underline'),
        ],
        ['← Go to Products'],
      ),
    ],
  )

const routeTitle = (route: Model['route']): string =>
  Match.value(route).pipe(
    Match.tag('Products', () => 'Shopping Cart'),
    Match.orElse(({ _tag }) => `${_tag} | Shopping Cart`),
  )

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  const routeContent = AppRoute.match(model.route, {
    Products: () => productsView(model, h),
    Cart: () => cartView(model, h),
    Checkout: () => checkoutView(model, h),
    NotFound: ({ path }) => notFoundView(path, h),
  })

  return {
    title: routeTitle(model.route),
    body: h.div(
      [h.Class('min-h-screen bg-gray-100')],
      [
        h.header(
          [],
          [navigationView(model.route, Cart.totalItems(model.cart), h)],
        ),
        h.main([h.Class('py-8')], [routeContent]),
      ],
    ),
  }
}