New
Ceramic pour-over
Matte stoneware, 500ml. Fires evenly and keeps heat.
$38.00
product listings
Matte stoneware, 500ml. Fires evenly and keeps heat.
$38.00
Forty grind settings, stepless. Quiet enough for a shared office.
$129.00
Rotating lots, shipped the day after roast.
$22.00
Copy this ERB into a Rails view after installing the component. The snippet below is the same code used by the live preview above.
<div class="grid w-full gap-4 sm:grid-cols-2 lg:grid-cols-3">
<%= render Senren::ProductCardComponent.new(
title: "Ceramic pour-over",
price: "$38.00",
url: demo_url,
description: "Matte stoneware, 500ml. Fires evenly and keeps heat.",
badge: "New"
) %>
<%= render Senren::ProductCardComponent.new(
title: "Burr grinder",
price: "$129.00",
url: demo_url,
description: "Forty grind settings, stepless. Quiet enough for a shared office.",
action_label: "Add to bag"
) %>
<%= render Senren::ProductCardComponent.new(
title: "Single-origin subscription",
price: "$22.00",
url: demo_url,
description: "Rotating lots, shipped the day after roast.",
available: false
) %>
</div>
Copy the official component into your app
Use this when you want the Senren-maintained implementation copied into app/components/senren.
bin/rails senren:add product_card
Create a custom component with the same conventions
Use this when you need an app-specific static component that follows Senren's ViewComponent structure.
bin/rails generate senren:component product_card --no-client
<%= tag.article(**root_attrs("flex flex-col overflow-hidden rounded-(--senren-radius) border bg-[hsl(var(--senren-card))] shadow-sm", id: dom_id)) do %>
<div class="relative aspect-square w-full bg-[hsl(var(--senren-muted)/0.4)]">
<% if safe_image_url %>
<%= image_tag safe_image_url, alt: title, loading: "lazy", class: "h-full w-full object-cover" %>
<% else %>
<div class="flex h-full w-full items-center justify-center text-sm text-[hsl(var(--senren-muted-foreground))]" aria-hidden="true">
No image
</div>
<% end %>
<% if badge %>
<span class="absolute left-2 top-2 inline-flex items-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-2 py-0.5 text-xs font-medium text-[hsl(var(--senren-primary-foreground))]">
<%= badge %>
</span>
<% end %>
</div>
<div class="flex flex-1 flex-col gap-2 p-4">
<h3 class="text-sm font-medium leading-tight text-[hsl(var(--senren-foreground))]">
<%= title %>
</h3>
<% if description %>
<p class="line-clamp-2 text-sm text-[hsl(var(--senren-muted-foreground))]"><%= description %></p>
<% end %>
<p class="mt-auto text-base font-semibold text-[hsl(var(--senren-foreground))]"><%= price %></p>
<%# A plain form: the server owns the cart, and Turbo handles the response. %>
<%= form_with url: safe_action_url, method: method, class: "mt-1" do %>
<button type="submit"
<% unless available? %>disabled<% end %>
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-(--senren-radius) bg-[hsl(var(--senren-primary))] px-4 text-sm font-medium text-[hsl(var(--senren-primary-foreground))] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--senren-ring))] disabled:cursor-not-allowed disabled:opacity-50">
<%= button_label %>
</button>
<% end %>
</div>
<% end %>
# frozen_string_literal: true
module Senren
# A product tile with an add-to-cart action.
#
# Adding to a cart is an ordinary form submission, so this component ships no
# Stimulus controller. That is deliberate: a listing page renders this tile
# many times, and a controller here would download JavaScript on a page that
# does not need any.
#
# Price is passed in already formatted. Currency formatting is locale- and
# money-library-specific and belongs to the host app; a UI library that
# guesses gets it wrong in another country.
class ProductCardComponent < BaseComponent
VARIANTS = {
default: 'border-[hsl(var(--senren-border))]',
featured: 'border-[hsl(var(--senren-primary))] ring-1 ring-[hsl(var(--senren-primary)/0.35)]'
}.freeze
SIZES = { md: '' }.freeze
def initialize(title:, price:, url:, image_url: nil, description: nil, badge: nil,
action_label: 'Add to cart', available: true, method: :post,
variant: :default, id: nil, class_name: nil, **html)
super(variant: variant, size: :md, class_name: class_name, **html)
@title = title
@price = price
@url = url
@image_url = image_url
@description = description
@badge = badge
@action_label = action_label
@available = available
@method = method
@dom_id = id || senren_dom_id(title)
end
attr_reader :title, :price, :image_url, :description, :badge, :action_label, :method, :dom_id
def available? = @available == true
# Untrusted values reach href/src attributes, so both go through the shared
# guards rather than being interpolated directly.
def safe_action_url = safe_url(@url)
def safe_image_url = safe_media_url(@image_url)
def button_label = available? ? action_label : 'Out of stock'
end
end
Use for
Avoid