Senren UI
Components / Cart

Cart

Stable Stimulus: senren--cart

cart summary panels

Cart

5
  • Ceramic pour-over

    $38.00

    1
  • Burr grinder

    $129.00

    1
  • Filter papers

    $18.00

    3

Empty cart

0

Your 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))]">&minus;</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.