Cart Totals
A shopping cart at the general store. Any change to a quantity, the delivery or the coupon is followed by every number that depends on it: the line total, the subtotal, the delivery fee and so on. The prices are worked out on the server, written once in Ruby.
Your order
-
260 gp
Steel Mace
260 gp each
-
40 gp
Bronze Dagger
20 gp each
-
24 gp
Loaf of Bread
4 gp each
-
8 gp
Cabbage
1 gp each
- Subtotal
- 332 gp
- Delivery by cart
- 30 gp
- Tithe, 10% of the goods
- 33 gp
- Total
- 395 gp
168 gp more and the cart comes free.
# frozen_string_literal: true
# An order at the general store. The states are what the visitor chose: the
# lines, the delivery and the coupon. Every number on the page is derived
# from them through Recipes::Totals::Order, which holds the prices.
class Recipes::Totals::TotalsChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Order = Recipes::Totals::Order
Line = Recipes::Totals::Line
VIEWS = "app/recipes/totals"
def build_graph
start = Order.start
@lines = Hibiki::State.new(start.lines)
@delivery = Hibiki::State.new(start.delivery)
@coupon = Hibiki::State.new(start.coupon)
@placed = Hibiki::State.new(false)
@order = Hibiki::Derived.new do
Order.new(lines: @lines.value, delivery: @delivery.value, coupon: @coupon.value)
end
ids = Hibiki::Derived.new { @lines.value.map(&:id) }
# The rows hold the quantity fields, so they are sent again only when
# the set of lines changes: one removed, or the basket filled again.
# `peek` reads the quantities without subscribing to them, and a field
# someone is typing in is never replaced.
Hibiki::Effect.new do
ids.value
broadcast_morph target: "totals_lines", partial: "#{VIEWS}/lines", locals: { lines: @lines.peek }
end
# A quantity changes two pieces of text in its own row instead. Each is
# sent only when its text is different from last time.
start.lines.each do |line|
transmit_value(:"totals_line_#{line.id}") { line(line.id)&.total&.then { Order.gp(it) } }
transmit_value(:"totals_note_#{line.id}") { line(line.id)&.note }
end
Hibiki::Effect.new do
broadcast_morph target: "totals_summary", partial: "#{VIEWS}/summary",
locals: { order: @order.value, placed: @placed.value }
end
end
def set_qty(data)
return if data["qty"].blank?
id = data["id"].to_i
qty = data["qty"].to_i.clamp(1, Line::MAX_QTY)
change(@lines, @lines.value.map { it.id == id ? it.with(qty:) : it })
end
def remove(data)
id = data["id"].to_i
change(@lines, @lines.value.reject { it.id == id })
end
def set_delivery(data)
change(@delivery, data["delivery"]) if Order::DELIVERIES.key?(data["delivery"])
end
def set_coupon(data) = change(@coupon, data["coupon"].to_s.strip.upcase.first(20))
def restock
change(@lines, Order.start.lines) if @lines.value.empty?
end
def place = @placed.value = !@order.value.empty?
private
# Any change to the order takes back "placed".
def change(state, value)
state.value = value
@placed.value = false
end
def line(id) = @lines.value.find { it.id == id }
end
# frozen_string_literal: true
# An order at the general store, and every rule that prices it. The page
# renders its first numbers from this, and the channel every number after,
# so a rule is written once.
class Recipes::Totals::Order < Data.define(:lines, :delivery, :coupon)
Line = Recipes::Totals::Line
Item = Recipes::Search::Item
Delivery = Data.define(:name, :fee, :free_from)
# What the basket holds when the page opens: item names and how many.
BASKET = { "Steel Mace" => 1, "Bronze Dagger" => 2, "Loaf of Bread" => 6, "Cabbage" => 8 }.freeze
# A way of delivering is free once the goods come to `free_from`.
DELIVERIES = {
"cart" => Delivery.new(name: "Cart", fee: 30, free_from: 500),
"rider" => Delivery.new(name: "Swift rider", fee: 80, free_from: 1_500),
"collect" => Delivery.new(name: "Collect it yourself", fee: 0, free_from: 0)
}.freeze
# What each coupon takes off the subtotal.
COUPONS = {
"CABBAGE" => ->(subtotal) { subtotal / 10 },
"CAMPFIRE" => ->(subtotal) { [ 50, subtotal ].min }
}.freeze
TITHE_PERCENT = 10
# The starting basket, priced from the shelves of the Live Search recipe.
def self.start
shelves = Item.where(name: BASKET.keys).pluck(:name, :id, :price).to_h { |name, *rest| [ name, rest ] }
lines = BASKET.filter_map do |name, qty|
id, price = shelves[name]
Line.new(id:, name:, price:, qty:) if id
end
new(lines:)
end
def self.gp(amount) = "#{amount.to_fs(:delimited)} gp"
def initialize(lines:, delivery: "cart", coupon: "") = super
def empty? = lines.empty?
def subtotal = lines.sum(&:total)
def coupon? = COUPONS.key?(coupon)
def discount = coupon? ? COUPONS[coupon].call(subtotal) : 0
# What the goods come to after the coupon. Delivery and the tithe are
# both worked out from this, so a coupon can cost you free delivery.
def goods = subtotal - discount
def delivery_method = DELIVERIES.fetch(delivery)
def free_delivery? = goods >= delivery_method.free_from
def delivery_fee = empty? || free_delivery? ? 0 : delivery_method.fee
def to_free_delivery = [ delivery_method.free_from - goods, 0 ].max
# How far along to free delivery, 0 to 100.
def progress
free_delivery? ? 100 : goods * 100 / delivery_method.free_from
end
# The king's share of the goods. Delivery is not tithed.
def tithe = (goods * TITHE_PERCENT).fdiv(100).round
def total = goods + delivery_fee + tithe
end
# frozen_string_literal: true
# One line of the basket in the Cart Totals recipe: something from the
# general store and how many of it. Ten or more of one thing is cheaper.
class Recipes::Totals::Line < Data.define(:id, :name, :price, :qty)
MAX_QTY = 99
BULK_FROM = 10
BULK_PERCENT = 10
def bulk? = qty >= BULK_FROM
# Whole gp. A fraction saved by the bulk price goes to the customer.
def total
full = price * qty
bulk? ? full - (full * BULK_PERCENT).fdiv(100).ceil : full
end
def note = bulk? ? "#{BULK_PERCENT}% off for #{BULK_FROM} or more" : ""
end
<%# The page prices the starting basket with the same Order the channel
uses. The delivery select and the coupon field are rendered here and
never again. %>
<% order = Recipes::Totals::Order.start %>
<%= island Recipes::Totals::TotalsChannel, class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5" do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">Your order</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<%= render "app/recipes/totals/lines", lines: order.lines %>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Delivery</span>
<%= tag.select name: "delivery", class: "select w-full", **on(:set_delivery, event: :change) do %>
<%= options_for_select Recipes::Totals::Order::DELIVERIES.map { |key, method| [ method.name, key ] }, order.delivery %>
<% end %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Coupon</span>
<%= tag.input type: "text", name: "coupon", maxlength: 20, autocomplete: "off", placeholder: "CABBAGE or CAMPFIRE",
class: "input w-full uppercase placeholder:normal-case", **on(:set_coupon, event: :input) %>
</label>
</div>
<%= render "app/recipes/totals/summary", order: %>
<% end %>
<%# locals: (lines:) -%>
<%# Morphed by the channel when a line goes or the basket is filled again.
Rows keep their ids, so the ones that stay are left as they are. %>
<div id="totals_lines" class="mt-3">
<ul id="totals_rows">
<%= render partial: "app/recipes/totals/line", collection: lines %>
</ul>
<% if lines.empty? %>
<div id="totals_empty" class="flex flex-wrap items-center justify-between gap-2 py-4">
<p class="text-sm opacity-70">The basket is empty.</p>
<%= tag.button "Fill it again", type: "button", class: "btn btn-sm", **on(:restock) %>
</div>
<% end %>
</div>
<%# locals: (line:) -%>
<%# The total and the bulk note are placeholders the channel writes text
into, so the quantity field beside them is never touched. %>
<li id="totals_row_<%= line.id %>" data-motion
class="grid grid-rows-[1fr] transition-[grid-template-rows,opacity] duration-300 ease-out
data-motion-leaving:grid-rows-[0fr] data-motion-leaving:opacity-0 motion-reduce:transition-none">
<div class="min-h-0 overflow-hidden">
<div class="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 border-b-2 border-base-300 py-2">
<div class="min-w-0 basis-full sm:flex-1 sm:basis-0">
<p class="font-semibold"><%= line.name %></p>
<p class="text-xs opacity-70">
<%= Recipes::Totals::Order.gp(line.price) %> each
<b class="ml-1 text-success"><%= reactive :"totals_note_#{line.id}", line.note %></b>
</p>
</div>
<%= tag.input type: "number", name: "qty", value: line.qty, min: 1, max: Recipes::Totals::Line::MAX_QTY,
inputmode: "numeric", "aria-label": "How many #{line.name}", class: "input input-sm w-18 text-right tabular-nums",
**on(:set_qty, event: :input, with: { id: line.id }) %>
<b class="w-20 text-right text-sm whitespace-nowrap tabular-nums"><%= reactive :"totals_line_#{line.id}", Recipes::Totals::Order.gp(line.total) %></b>
<%= tag.button type: "button", "aria-label": "Remove #{line.name}", class: "btn btn-ghost btn-sm btn-circle",
**on(:remove, with: { id: line.id }) do %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" class="size-4"><path d="M6 6l12 12M18 6 6 18"/></svg>
<% end %>
</div>
</div>
</li>
<%# locals: (order:, placed: false) -%>
<%# Everything the order comes to. It only asks the order; no price is
worked out here. %>
<% gp = Recipes::Totals::Order.method(:gp) %>
<% method = order.delivery_method %>
<div id="totals_summary" class="mt-4 border-t-2 border-base-300 pt-3">
<dl class="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm tabular-nums">
<dt>Subtotal</dt>
<dd class="text-right"><%= gp.(order.subtotal) %></dd>
<% if order.coupon? %>
<dt id="totals_coupon">Coupon <%= order.coupon %></dt>
<dd class="text-right text-success">−<%= gp.(order.discount) %></dd>
<% elsif order.coupon.present? %>
<dt id="totals_coupon" class="col-span-2 opacity-70">No coupon called <%= order.coupon %>.</dt>
<% end %>
<dt>Delivery by <%= method.name.downcase %></dt>
<dd class="text-right"><%= order.delivery_fee.zero? ? "Free" : gp.(order.delivery_fee) %></dd>
<dt>Tithe, <%= Recipes::Totals::Order::TITHE_PERCENT %>% of the goods</dt>
<dd class="text-right"><%= gp.(order.tithe) %></dd>
<dt class="mt-1 text-base font-bold">Total</dt>
<dd class="mt-1 text-right text-base font-bold"><%= gp.(order.total) %></dd>
</dl>
<% if method.fee.positive? && !order.empty? %>
<div id="totals_progress" class="mt-3">
<div class="h-3 overflow-hidden rounded-full border-2 border-base-300 bg-base-200">
<div class="h-full bg-primary transition-[width] duration-500 ease-out motion-reduce:transition-none"
style="width: <%= order.progress %>%"></div>
</div>
<p class="mt-1 text-sm">
<% if order.free_delivery? %>
<%= rainbow_text "Free delivery!", class: "font-bold" %>
<% else %>
<%= gp.(order.to_free_delivery) %> more and the <%= method.name.downcase %> comes free.
<% end %>
</p>
</div>
<% end %>
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<%= tag.button "Place order", type: "button", class: "btn btn-primary btn-sm",
disabled: order.empty? || placed, **on(:place) %>
<% if placed %>
<p class="text-sm">Order placed. <%= gp.(order.total) %>, and not a cabbage more.</p>
<% end %>
</div>
</div>
The cart above holds four things from the general store, each with a field at the right of its row for how many. Type a new number into one of those fields and the rest of the panel follows: the row's total, the subtotal, the delivery fee and so on down to the total. Make it ten and the row shows the bulk price. Keeping all of that in step is tedious in stock Rails, and tempting to do in JavaScript, where the prices are then written twice. Here every price is worked out on the server, in Ruby, in one place. The channel writes down what the visitor chose, and Hibiki sends the parts of the page that were worked out from it.
How it fits together
The basket lives in a channel: the server side of an Action
Cable connection, an object that stays alive while the page is open. A
state holds a value and remembers who read it. Here three states
hold what the visitor chose: @lines,
@delivery and @coupon. The channel's public
methods are its actions, the methods the page may call, and each
one writes one of the three.
A derived is a value worked out from other values, and worked
out again when one of them changes. Here @order is the one
derived. It reads the three states and builds an Order from
them: a plain Ruby value that holds every pricing rule and knows nothing
about the channel.
An effect is a block of code that runs once, and runs again
whenever a value it read has changed. One effect reads
@order, renders the summary partial and sends it to the
page, where it is morphed into place. This is what happens when you pick
Swift rider in the delivery select:
-
The select calls
set_delivery, which writes@deliveryand returns. -
@orderread@delivery, so it is built again with the rider's fee and mark. -
The summary effect read
@order, so it renders the summary again and sends it. The fee, the total and the bar come with it.
The channel never says which effect depends on which state. Whatever a
block read on its last run is what it depends on. The figures inside a
row take a shorter road: transmit_value is an effect that
sends a piece of text instead of HTML, and sends it only when the text
differs from last time.
The prices are written once
A shop's rules lean on one another. The coupon takes a share of the
subtotal, delivery is free once the goods come to a mark, and the tithe
is a share of the goods. In Order each amount is a method
that reads the methods before it:
# app/models/recipes/totals/order.rb
def subtotal = lines.sum(&:total)
def discount = coupon? ? COUPONS[coupon].call(subtotal) : 0
def goods = subtotal - discount
def delivery_fee = empty? || free_delivery? ? 0 : delivery_method.fee
def tithe = (goods * TITHE_PERCENT).fdiv(100).round
def total = goods + delivery_fee + tithe
goods is the subtotal with the discount taken off, and both
delivery_fee and tithe read it. Now follow a
coupon through the chain. Type CABBAGE into the coupon field
on an order that has only just reached free delivery. The discount lowers
the goods, the goods fall back under the mark, the fee returns, and the
tithe shrinks with the goods. We didn't write the case where a coupon costs
you free delivery: it follows from the fee and the tithe both reading
goods.
The page's first render calls the same methods: _totals
builds an Order.start and renders the rows and the summary
from it. You may be tempted to build the chain out of deriveds in the
channel instead. However, that chain would be a second copy of the
pricing, and the first render, which has no channel yet, could not use
it. Keeping the rules in a plain value and wrapping it in one derived
gives the first render and every update the same answer.
The field being typed in is never replaced
Each row holds a quantity field. If the rows were sent by an effect that
read @lines, the whole list would arrive again for a
one-number change, with the field you are typing in inside it. So the
rows are sent again only when a line comes or goes, and a quantity
changes two pieces of text beside the field instead:
ids = Hibiki::Derived.new { @lines.value.map(&:id) }
Hibiki::Effect.new do
ids.value # subscribes: a line gone, new rows
broadcast_morph target: "totals_lines", partial: "#{VIEWS}/lines",
locals: { lines: @lines.peek } # does not: a quantity sends nothing here
end
# what a quantity does send: the text of its own row
transmit_value(:"totals_line_#{line.id}") { line(line.id)&.total&.then { Order.gp(it) } }
ids is a derived list of the line ids. The effect reads
ids.value, which subscribes it to the list, and renders the
rows from @lines.peek, which reads the lines without
subscribing to them. Type a new quantity and @lines becomes
a new list, but ids comes to the same ids as before, so the
effect stays quiet and the row's transmit_value sends the
new total on its own. Removing a line instead changes ids to a
shorter list: the effect runs, the morph leaves the remaining fields as
they are, and the removed row slides out.
Changing a rule
Say the shopkeeper raises the mark for free delivery by cart, or starts
offering gift wrap. The first is one number in Order. The
second is a method there, a row in the summary, and a state and an action
in the channel for the visitor's choice:
# Free delivery by cart from 750 gp: one number.
"cart" => Delivery.new(name: "Cart", fee: 30, free_from: 750),
# Gift wrap at 2 gp a line: a state and an action in the channel,
# a field and a method in the order, and a row in the summary partial.
@wrap = Hibiki::State.new(false)
def set_wrap(data) = change(@wrap, data["wrap"] == true)
def wrap_fee = wrap ? lines.size * 2 : 0
def total = goods + delivery_fee + wrap_fee + tithe
Neither change touches an effect. The summary effect reads the order as it did before, so the bar follows the new mark and the gift wrap row arrives with the rest of the summary.
The same feature in stock Rails
The usual stock Rails build that keeps the prices in Ruby posts the whole
form on every change and answers with Turbo Streams. Here is a sketch of
it. It is not run here, and it leaves out Order,
Line and the summary partial, which would be the same in
both versions.
# A sketch of the stock Rails version. It is not part of this app.
class BasketsController < ApplicationController
before_action :set_order
def show
end
# Every keystroke that settles posts the whole form: each quantity, the
# delivery and the coupon. Nothing is remembered between requests, so the
# order is rebuilt from all of it and saved back.
def update
@before = @order
@order = @order.with(
lines: @order.lines.map { it.with(qty: quantity(it.id) || it.qty) },
delivery: params[:delivery].presence_in(Order::DELIVERIES.keys) || @order.delivery,
coupon: params[:coupon].to_s.strip.upcase.first(20)
)
session[:basket] = @order.to_session
# The template has to know what changed, so that it does not replace
# more than it needs to. Here that is every line whose total moved.
@changed = @order.lines.reject { |line| @before.lines.include?(line) }
respond_to do |format|
format.turbo_stream
format.html { redirect_to basket_path }
end
end
private
def set_order = @order = Order.from_session(session[:basket])
def quantity(id) = params.dig(:qty, id.to_s)&.to_i&.clamp(1, Line::MAX_QTY)
end
# Removing a line is its own controller, action and template.
class Baskets::LinesController < ApplicationController
def destroy
@order = Order.from_session(session[:basket])
@line_id = params[:id].to_i
@order = @order.with(lines: @order.lines.reject { it.id == @line_id })
session[:basket] = @order.to_session
respond_to do |format|
format.turbo_stream # destroy.turbo_stream.erb: remove the row, then the same summary line
format.html { redirect_to basket_path }
end
end
end
<%# The list of everything a change can move, kept by hand. The quantity
fields must not be replaced while someone types in them, so each row's
total and note get an id and a line of their own. %>
<% @changed.each do |line| %>
<%= turbo_stream.update "line_total_#{line.id}", number_to_gp(line.total) %>
<%= turbo_stream.update "line_note_#{line.id}", line.note %>
<% end %>
<%# The summary holds no field, so it can be replaced whole. Had the coupon
field been inside it, it would need the same care as the rows. %>
<%= turbo_stream.replace "basket_summary", partial: "baskets/summary", locals: { order: @order } %>
<%# destroy.turbo_stream.erb repeats the summary line and adds these. %>
<%# turbo_stream.remove "line_#{@line_id}" %>
<%# turbo_stream.replace "basket_empty", partial: "baskets/empty" if @order.empty? %>
<%# One form around everything, so that a change can post all of it. %>
<%= form_with url: basket_path, method: :patch,
data: { controller: "basket", action: "input->basket#changed change->basket#changed" } do |form| %>
<ul>
<% @order.lines.each do |line| %>
<li id="line_<%= line.id %>">
<%= line.name %>
<span id="line_note_<%= line.id %>"><%= line.note %></span>
<%= form.number_field "qty[#{line.id}]", value: line.qty, min: 1, max: Line::MAX_QTY %>
<span id="line_total_<%= line.id %>"><%= number_to_gp(line.total) %></span>
<%# A button inside the form would submit it, so removal goes to
its own route. %>
<%= link_to "Remove", basket_line_path(line.id), data: { turbo_method: :delete } %>
</li>
<% end %>
</ul>
<div id="basket_empty"></div>
<%= form.select :delivery, Order::DELIVERIES.map { |key, method| [ method.name, key ] }, selected: @order.delivery %>
<%= form.text_field :coupon, value: @order.coupon %>
<% end %>
<%= render "baskets/summary", order: @order %>
import { Controller } from "@hotwired/stimulus"
// Posts the form a moment after the last change. Turbo sends it as a
// fetch and applies the streams that come back.
export default class extends Controller {
static values = { wait: { type: Number, default: 250 } }
changed() {
clearTimeout(this.timer)
this.timer = setTimeout(() => this.element.requestSubmit(), this.waitValue)
}
// Two posts can be in flight at once, and the slower one may answer
// last with older numbers. Cancelling the earlier request is more code,
// left out here.
disconnect() {
clearTimeout(this.timer)
}
}
# One basket for each visitor, kept in the session or a table.
resource :basket, only: %i[show update] do
resources :lines, only: :destroy, module: :baskets
end
The pricing is as short as before. What grows is everything around it. The server remembers nothing between requests, so the form posts every field each time and the controller rebuilds the order from all of it, then compares it with the order before to learn which rows moved, since the stream template has to name each place a number appears. Removing a line needs its own route, controller and template. The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| A line total follows its quantity | An id on the total, and a Turbo Stream line for each row that changed | One transmit_value for the row |
| Subtotal, tithe, delivery and total follow | A Turbo Stream line for the summary, repeated in every template that can change it | One effect and its partial |
| Knowing what changed | The controller compares the order before and after | Nothing to write; each effect runs when what it read has changed |
| What a change sends to the server | Every field of the form | The one value that changed |
| Not replacing the field in use | Keep fields out of every replaced fragment, by hand, in each template | The rows subscribe to which lines exist and peek at the quantities |
| Removing a line | A route, a controller, an action and a second stream template | A one-line action |
| Waiting for typing to settle | A Stimulus controller with a timer | on(:set_qty, event: :input), which waits 250 ms by default |
| Two quick changes in a row | Replies can arrive out of order; guarding against it is more JavaScript | Actions run one at a time, in the order sent |
| A new rule, such as gift wrap | The method and the summary row, plus the field in the form, the param in the controller and the session | The method and the summary row, plus a state and an action |
Stock Rails has a second way with no round trip: work the totals out in a Stimulus controller. It is quick to the eye. However, the bulk price, the coupons and the delivery tiers are then written twice, in Ruby for the order that is charged and in JavaScript for the one that is shown, and the day one copy changes without the other, the page shows a total the shop does not charge. Here the prices stay on the server, and Hibiki carries them to the page.