All ExamplesView source on GitHub
Shopping Cart
E-commerce app with product listing, cart management, and checkout flow.
Routing
/
import { Effect, Match as M, Option, Schema as S, pipe } from 'effect'
import { Route, Runtime } from 'foldkit'
import { Command } from 'foldkit/command'
import { Html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { load, pushUrl } from 'foldkit/navigation'
import { literal, r } from 'foldkit/route'
import { evo } from 'foldkit/struct'
import { Url, toString as urlToString } from 'foldkit/url'
import { products } from './data/products'
import { Cart, Item } from './domain'
import {
Class,
Href,
a,
div,
h1,
header,
keyed,
li,
main,
nav,
p,
ul,
} from './html'
import { Cart as CartPage, Checkout, Products } from './page'
// ROUTE
export const ProductsRoute = r('Products', { searchText: S.Option(S.String) })
export const CartRoute = r('Cart')
export const CheckoutRoute = r('Checkout')
export const NotFoundRoute = r('NotFound', { path: S.String })
export const AppRoute = S.Union(
ProductsRoute,
CartRoute,
CheckoutRoute,
NotFoundRoute,
)
export type ProductsRoute = typeof ProductsRoute.Type
export type CartRoute = typeof CartRoute.Type
export type CheckoutRoute = typeof CheckoutRoute.Type
export type NotFoundRoute = typeof NotFoundRoute.Type
export type AppRoute = typeof AppRoute.Type
const productsRouter = pipe(
Route.root,
Route.query(S.Struct({ searchText: S.OptionFromUndefinedOr(S.String) })),
Route.mapTo(ProductsRoute),
)
const cartRouter = pipe(literal('cart'), Route.mapTo(CartRoute))
const checkoutRouter = pipe(literal('checkout'), Route.mapTo(CheckoutRoute))
const routeParser = Route.oneOf(checkoutRouter, cartRouter, productsRouter)
const urlToAppRoute = Route.parseUrlWithFallback(routeParser, NotFoundRoute)
// MODEL
const Model = S.Struct({
route: AppRoute,
cart: Cart.Cart,
deliveryInstructions: S.String,
orderPlaced: S.Boolean,
productsPage: Products.Model,
})
type Model = typeof Model.Type
// MESSAGE
const NoOp = m('NoOp')
const ClickedLink = m('ClickedLink', {
request: Runtime.UrlRequest,
})
const ChangedUrl = m('ChangedUrl', { url: Url })
const GotProductsMessage = m('GotProductsMessage', {
message: Products.Message,
})
const ClickedAddToCart = m('ClickedAddToCart', { item: Item.Item })
const ClickedQuantityChange = m('ClickedQuantityChange', {
itemId: S.String,
quantity: S.Number,
})
export const ClickedCartQuantityChange = m('ClickedCartQuantityChange', {
itemId: S.String,
quantity: S.Number,
})
export const ClickedRemoveCartItem = m('ClickedRemoveCartItem', {
itemId: S.String,
})
export const ClickedClearCart = m('ClickedClearCart')
export const UpdatedDeliveryInstructions = m('UpdatedDeliveryInstructions', {
value: S.String,
})
export const ClickedPlaceOrder = m('ClickedPlaceOrder')
export const Message = S.Union(
NoOp,
ClickedLink,
ChangedUrl,
GotProductsMessage,
ClickedAddToCart,
ClickedQuantityChange,
ClickedCartQuantityChange,
ClickedRemoveCartItem,
ClickedClearCart,
UpdatedDeliveryInstructions,
ClickedPlaceOrder,
)
export type Message = typeof Message.Type
// INIT
const init: Runtime.ApplicationInit<Model, Message> = (url: Url) => {
return [
{
route: urlToAppRoute(url),
cart: [],
deliveryInstructions: '',
orderPlaced: false,
productsPage: Products.init(products),
},
[],
]
}
// UPDATE
const update = (
model: Model,
message: Message,
): [Model, ReadonlyArray<Command<Message>>] =>
M.value(message).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<Message>>]>(),
M.tagsExhaustive({
NoOp: () => [model, []],
ClickedLink: ({ request }) =>
M.value(request).pipe(
M.withReturnType<[Model, ReadonlyArray<Command<typeof NoOp>>]>(),
M.tagsExhaustive({
Internal: ({ url }) => [
model,
[pushUrl(urlToString(url)).pipe(Effect.as(NoOp()))],
],
External: ({ href }) => [
model,
[load(href).pipe(Effect.as(NoOp()))],
],
}),
),
ChangedUrl: ({ url }) => [
evo(model, {
route: () => urlToAppRoute(url),
}),
[],
],
GotProductsMessage: ({ message }) => {
const [newProductsModel, commands] = Products.update(productsRouter)(
model.productsPage,
message,
)
return [
evo(model, {
productsPage: () => newProductsModel,
}),
commands.map(Effect.map(message => GotProductsMessage({ message }))),
]
},
ClickedAddToCart: ({ item }) => [
evo(model, {
cart: () => Cart.addItem(item)(model.cart),
}),
[],
],
ClickedQuantityChange: ({ itemId, quantity }) => [
evo(model, {
cart: () => Cart.changeQuantity(itemId, quantity)(model.cart),
}),
[],
],
ClickedCartQuantityChange: ({ itemId, quantity }) => [
evo(model, {
cart: () => Cart.changeQuantity(itemId, quantity)(model.cart),
}),
[],
],
ClickedRemoveCartItem: ({ itemId }) => [
evo(model, {
cart: () => Cart.removeItem(itemId)(model.cart),
}),
[],
],
ClickedClearCart: () => [
evo(model, {
cart: () => [],
}),
[],
],
UpdatedDeliveryInstructions: ({ value }) => [
evo(model, {
deliveryInstructions: () => value,
}),
[],
],
ClickedPlaceOrder: () => [
evo(model, {
orderPlaced: () => true,
cart: () => [],
deliveryInstructions: () => '',
}),
[],
],
}),
)
// VIEW
const navigationView = (currentRoute: AppRoute, cartCount: number): 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 nav(
[Class('bg-blue-500 text-white p-4 mb-6')],
[
ul(
[Class('max-w-6xl mx-auto flex gap-6 justify-center list-none')],
[
li(
[],
[
a(
[
Href(productsRouter({ searchText: Option.none() })),
Class(navLinkClassName(currentRoute._tag === 'Products')),
],
['Products'],
),
],
),
li(
[],
[
a(
[
Href(cartRouter()),
Class(navLinkClassName(currentRoute._tag === 'Cart')),
],
cartCount > 0 ? [`Cart (${cartCount})`] : ['Cart'],
),
],
),
li(
[],
[
a(
[
Href(checkoutRouter()),
Class(navLinkClassName(currentRoute._tag === 'Checkout')),
],
['Checkout'],
),
],
),
],
),
],
)
}
const productsView = (model: Model): Html => {
return Products.view(
model.productsPage,
model.cart,
cartRouter,
message => GotProductsMessage({ message }),
item => ClickedAddToCart({ item }),
(itemId, quantity) => ClickedQuantityChange({ itemId, quantity }),
)
}
const cartView = (model: Model): Html => {
return CartPage.view(model.cart, productsRouter, checkoutRouter)
}
const checkoutView = (model: Model): Html => {
return Checkout.view(
model.cart,
model.deliveryInstructions,
model.orderPlaced,
productsRouter,
cartRouter,
)
}
const notFoundView = (path: string): Html =>
div(
[Class('max-w-4xl mx-auto px-4 text-center')],
[
h1(
[Class('text-4xl font-bold text-red-600 mb-6')],
['404 - Page Not Found'],
),
p(
[Class('text-lg text-gray-600 mb-4')],
[`The path "${path}" was not found.`],
),
a(
[
Href(productsRouter({ searchText: Option.none() })),
Class('text-blue-500 hover:underline'),
],
['← Go to Products'],
),
],
)
const view = (model: Model): Html => {
const routeContent = M.value(model.route).pipe(
M.tagsExhaustive({
Products: () => productsView(model),
Cart: () => cartView(model),
Checkout: () => checkoutView(model),
NotFound: ({ path }) => notFoundView(path),
}),
)
return div(
[Class('min-h-screen bg-gray-100')],
[
header([], [navigationView(model.route, Cart.totalItems(model.cart))]),
main(
[Class('py-8')],
[keyed('div')(model.route._tag, [], [routeContent])],
),
],
)
}
// RUN
const app = Runtime.makeApplication({
Model,
init,
update,
view,
container: document.getElementById('root')!,
browser: {
onUrlRequest: request => ClickedLink({ request }),
onUrlChange: url => ChangedUrl({ url }),
},
})
Runtime.run(app)