Cart
Stable Stimulus:senren--cart
cart summary panels
Cart
5-
Ceramic pour-over
$38.00
-
Burr grinder
$129.00
-
Filter papers
$18.00
Empty cart
0Your cart is empty.
Usage example #
Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.
app/views/.../cart_example.html.erb
<%#
price is passed pre-formatted and price_cents as an integer, on purpose:
the component displays the first and does arithmetic on the second. Doing
the maths on a formatted string is how currency bugs start.
%>
<div class="grid w-full gap-6 lg:grid-cols-2">
<div class="max-w-md">
<%= render Senren::CartComponent.new(
currency: "$",
checkout_url: demo_url,
items: [
{ id: 1, name: "Ceramic pour-over", price_cents: 3800, quantity: 1, remove_url: demo_url },
{ id: 2, name: "Burr grinder", price_cents: 12_900, quantity: 1, remove_url: demo_url },
{ id: 3, name: "Filter papers", price_cents: 600, quantity: 3, remove_url: demo_url }
]
) %>
</div>
<div class="max-w-md">
<%= render Senren::CartComponent.new(title: "Empty cart", checkout_url: demo_url) %>
</div>
</div>
Install this component #
Copy the official component into your app
This component requires Stimulus. Keep --client so the controller is copied with the ViewComponent.
Terminal
bin/rails senren:add cart --client
Create a custom component with the same conventions
Use this when you need an app-specific component that follows Senren's ViewComponent and Stimulus structure. --client is required for this behavior.
Terminal
bin/rails generate senren:component cart --client
At a glance #
Category
Saas
Class name
Senren::CartComponent
Stimulus
senren--cart
Variants
default, flush
Depends on
—
Pairs with
product_card, button, separator, empty_state
Source #
app/components/senren/cart_component.html.erb
<%= tag.section(**root_attrs("flex flex-col rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id, "aria-label": title, data: { controller: "senren--cart", "senren--cart-currency-value": currency, "senren--cart-subtotal-cents-value": subtotal_cents })) do %>
<header class="flex items-center justify-between border-b border-[hsl(var(--senren-border))] px-4 py-3">
<h2 class="text-sm font-semibold text-[hsl(var(--senren-foreground))]"><%= title %></h2>
<span class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="count"><%= total_quantity %></span>
</header>
<% if empty? %>
<p class="px-4 py-8 text-center text-sm text-[hsl(var(--senren-muted-foreground))]"><%= empty_text %></p>
<% else %>
<ul class="divide-y divide-[hsl(var(--senren-border))]">
<% items.each do |item| %>
<li class="flex items-center gap-3 px-4 py-3"
data-senren--cart-target="line"
data-line-id="<%= item[:id] %>"
<%# Emitted so the `removed` event can carry it. The component
accepted remove_url: and normalised it, then never wrote it
anywhere -- an advertised input that reached nothing, so the
consumer listening for the event had no way to learn where to
send the delete. %>
<% if item[:remove_url].present? %>data-remove-url="<%= safe_url(item[:remove_url]) %>"<% end %>
data-price-cents="<%= item[:price_cents] %>">
<% if (image = safe_media_url(item[:image_url])) %>
<%= image_tag image, alt: "", loading: "lazy", class: "h-12 w-12 shrink-0 rounded-(--senren-radius) object-cover" %>
<% else %>
<div class="h-12 w-12 shrink-0 rounded-(--senren-radius) bg-[hsl(var(--senren-muted)/0.5)]" aria-hidden="true"></div>
<% end %>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-[hsl(var(--senren-foreground))]"><%= item[:name] %></p>
<p class="text-sm text-[hsl(var(--senren-muted-foreground))]" data-senren--cart-target="lineTotal"><%= line_total(item) %></p>
</div>
<div class="flex items-center gap-1" role="group" aria-label="<%= item[:name] %> quantity">
<button type="button"
data-action="click->senren--cart#decrement"
aria-label="Decrease <%= item[:name] %> quantity"
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">−</button>
<output class="w-8 text-center text-sm tabular-nums text-[hsl(var(--senren-foreground))]"
data-senren--cart-target="quantity"><%= item[:quantity] %></output>
<button type="button"
data-action="click->senren--cart#increment"
aria-label="Increase <%= item[:name] %> quantity"
class="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-(--senren-radius) border border-[hsl(var(--senren-border))] text-sm hover:bg-[hsl(var(--senren-accent))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">+</button>
</div>
<button type="button"
data-action="click->senren--cart#remove"
aria-label="Remove <%= item[:name] %>"
class="inline-flex h-8 cursor-pointer items-center rounded-(--senren-radius) px-2 text-sm text-[hsl(var(--senren-muted-foreground))] hover:bg-[hsl(var(--senren-accent))] hover:text-[hsl(var(--senren-destructive))] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]">Remove</button>
</li>
<% end %>
</ul>
<footer class="flex items-center justify-between gap-3 border-t border-[hsl(var(--senren-border))] px-4 py-3">
<div class="text-sm text-[hsl(var(--senren-muted-foreground))]">
Subtotal
<span class="ml-1 font-semibold text-[hsl(var(--senren-foreground))]" data-senren--cart-target="subtotal"><%= subtotal %></span>
</div>
<% if safe_checkout_url %>
<a href="<%= safe_checkout_url %>" class="inline-flex h-10 cursor-pointer items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))]"><%= checkout_label %></a>
<% end %>
</footer>
<% end %>
<% end %>
app/components/senren/cart_component.rb
# frozen_string_literal: true
module Senren
# A shopping cart: line items with quantity steppers, a live subtotal, and
# removal.
#
# The server owns the cart. This component owns the widget. The Stimulus
# controller only does what does not round-trip pleasantly — stepping a
# quantity and recomputing the displayed subtotal while the user clicks — and
# announces the result with senren--cart:changed so a host app can update a
# header badge without reaching inside.
#
# Money arrives twice, and on purpose: `price` already formatted for display,
# and `price_cents` as an integer for arithmetic. Formatting is locale- and
# money-library-specific and belongs to the host app. Doing arithmetic on a
# formatted string is how currency bugs start.
class CartComponent < BaseComponent
VARIANTS = {
default: 'border-[hsl(var(--senren-border))]',
flush: 'border-transparent shadow-none'
}.freeze
SIZES = { md: '' }.freeze
def initialize(items: [], currency: '$', title: 'Cart', empty_text: 'Your cart is empty.',
checkout_url: nil, checkout_label: 'Checkout', variant: :default,
id: nil, class_name: nil, **html)
super(variant: variant, size: :md, class_name: class_name, **html)
@items = Array(items).map { |item| normalize_item(item) }
@currency = currency
@title = title
@empty_text = empty_text
@checkout_url = checkout_url
@checkout_label = checkout_label
@dom_id = id || senren_dom_id(title)
end
attr_reader :items, :currency, :title, :empty_text, :checkout_label, :dom_id
def empty? = items.empty?
def total_quantity = items.sum { |item| item[:quantity] }
def subtotal_cents = items.sum { |item| item[:price_cents] * item[:quantity] }
def subtotal = format_cents(subtotal_cents)
def line_total(item) = format_cents(item[:price_cents] * item[:quantity])
def safe_checkout_url = @checkout_url && safe_url(@checkout_url)
# Kept public so the same rendering is available to a host app updating a
# line through a Turbo Stream.
# Kernel.format explicitly: ViewComponent::Base defines its own `format`
# (the current template format, taking no arguments), which shadows
# Kernel#format inside a component. On Rails 8.x this happened to resolve;
# on 7.1 it raised "wrong number of arguments (given 2, expected 0)" as soon
# as the call ran inside a tag block. Caught by the version matrix.
def format_cents(cents)
"#{currency}#{Kernel.format('%.2f', cents.to_i / 100.0)}"
end
private
def normalize_item(item)
{
id: item[:id] || item['id'],
name: item[:name] || item['name'],
price_cents: (item[:price_cents] || item['price_cents']).to_i,
quantity: [(item[:quantity] || item['quantity'] || 1).to_i, 1].max,
image_url: item[:image_url] || item['image_url'],
remove_url: item[:remove_url] || item['remove_url']
}
end
end
end
AI agent rules #
Use for
- +cart summary panels
- +checkout side panels
- +order review before payment
Avoid
- -persisting quantities; the controller is display-only and the server is authoritative
- -formatting currency in the component; pass price_cents and a formatted price
Accessibility #
- Quantity steppers are grouped with an accessible name per line.
- Subtotal is text, not an image, so it is announced on change.