berrypod/lib/berrypod_web/components/shop_components/cart.ex
jamey 0b0adba0fe
All checks were successful
deploy / deploy (push) Successful in 1m23s
add no-JS progressive enhancement for all shop flows
Every key shop flow now works via plain HTML forms when JS is
unavailable. LiveView progressively enhances when JS connects.

- PDP: form wraps variant/qty/add-to-cart with action="/cart/add"
- Cart page: qty +/- and remove use form POST fallbacks
- Cart/search header icons are now links with phx-click enhancement
- Collection sort form has GET action + noscript submit button
- New /search page with form-based search for no-JS users
- CartController gains add/remove/update_item POST actions
- CartHook gains update_quantity_form and remove_item_form handlers
- Fix flaky analytics tests caused by event table pollution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:56:19 +00:00

549 lines
16 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule BerrypodWeb.ShopComponents.Cart do
@moduledoc false
use Phoenix.Component
import BerrypodWeb.ShopComponents.Base
alias Berrypod.Products.{Product, ProductImage}
defp close_cart_drawer_js do
Phoenix.LiveView.JS.push("close_cart_drawer")
end
@doc """
Renders the cart drawer (floating sidebar).
The drawer slides in from the right when opened. It displays cart items
and checkout options. Follows WAI-ARIA dialog pattern for accessibility.
## Attributes
* `cart_items` - List of cart items to display. Each item should have
`image`, `name`, `variant`, `price`, and `variant_id` keys. Default: []
* `subtotal` - The subtotal to display. Default: nil (shows "£0.00")
* `cart_count` - Number of items for screen reader description. Default: 0
* `mode` - Either `:live` (default) for real stores or `:preview` for theme editor.
In preview mode, "View basket" navigates via LiveView JS commands.
## Examples
<.cart_drawer cart_items={@cart.items} subtotal={@cart.subtotal} />
<.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} />
"""
attr :cart_items, :list, default: []
attr :subtotal, :string, default: nil
attr :total, :string, default: nil
attr :cart_count, :integer, default: 0
attr :cart_status, :string, default: nil
attr :mode, :atom, default: :live
attr :open, :boolean, default: false
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
def cart_drawer(assigns) do
assigns =
assign_new(assigns, :display_total, fn ->
assigns.total || assigns.subtotal || "£0.00"
end)
~H"""
<%!-- Screen reader announcements for cart changes --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{@cart_status}
</div>
<!-- Cart Drawer Overlay -->
<div
id="cart-drawer-overlay"
class={["cart-drawer-overlay", @open && "open"]}
aria-hidden={to_string(!@open)}
phx-click={close_cart_drawer_js()}
>
</div>
<!-- Cart Drawer -->
<div
id="cart-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="cart-drawer-title"
aria-describedby="cart-drawer-description"
aria-hidden={to_string(!@open)}
phx-hook="CartDrawer"
class={["cart-drawer", @open && "open"]}
>
<p id="cart-drawer-description" class="sr-only">
Shopping basket with {@cart_count} {if @cart_count == 1, do: "item", else: "items"}. Press Escape to close.
</p>
<div class="cart-drawer-header">
<h2
id="cart-drawer-title"
class="cart-drawer-title"
>
Your basket
</h2>
<button
type="button"
class="cart-drawer-close"
phx-click={close_cart_drawer_js()}
aria-label="Close cart"
>
<svg
class="cart-drawer-close-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="cart-drawer-items">
<%= if @cart_items == [] do %>
<.cart_empty_state mode={@mode} />
<% else %>
<ul role="list" aria-label="Cart items">
<%= for item <- @cart_items do %>
<li>
<.cart_item_row item={item} size={:compact} show_quantity_controls mode={@mode} />
</li>
<% end %>
</ul>
<% end %>
</div>
<div class="cart-drawer-footer">
<.delivery_line
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
mode={@mode}
/>
<div class="cart-drawer-total">
<span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
<span>{@display_total}</span>
</div>
<%= if @mode == :preview do %>
<button
type="button"
class="cart-drawer-checkout"
>
Checkout
</button>
<% else %>
<form action="/checkout" method="post">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<button
type="submit"
class="cart-drawer-checkout"
>
Checkout
</button>
</form>
<% end %>
</div>
</div>
"""
end
@doc """
Shared cart item row component used by both drawer and cart page.
## Attributes
* `item` - Required. Cart item with `name`, `variant`, `price`, `quantity`, `image`, `variant_id`, `product_id`.
* `size` - Either `:compact` (drawer) or `:default` (cart page). Default: :default
* `show_quantity_controls` - Show +/- buttons. Default: false
* `mode` - Either `:live` or `:preview`. Default: :live
"""
attr :item, :map, required: true
attr :size, :atom, default: :default
attr :show_quantity_controls, :boolean, default: false
attr :mode, :atom, default: :live
def cart_item_row(assigns) do
~H"""
<div
class="cart-item-row"
data-size={if @size == :compact, do: "compact"}
>
<%= if @mode != :preview do %>
<.link
navigate={"/products/#{@item.product_id}"}
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
data-size={if @size == :compact, do: "compact"}
style={if @item.image, do: "background-image: url('#{@item.image}');"}
aria-label={"View #{@item.name}"}
>
</.link>
<% else %>
<div
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
data-size={if @size == :compact, do: "compact"}
style={if @item.image, do: "background-image: url('#{@item.image}');"}
>
</div>
<% end %>
<div class="cart-item-details">
<h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
<%= if @mode != :preview do %>
<.link
navigate={"/products/#{@item.product_id}"}
class="cart-item-name-link"
>
{@item.name}
</.link>
<% else %>
<span>{@item.name}</span>
<% end %>
</h3>
<%= if @item.variant do %>
<p class="cart-item-variant-text">
{@item.variant}
</p>
<% end %>
<div class="cart-item-actions">
<%= if @show_quantity_controls do %>
<form
action="/cart/update"
method="post"
phx-submit="update_quantity_form"
class="cart-qty-group"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input type="hidden" name="variant_id" value={@item.variant_id} />
<button
type="submit"
name="quantity"
value={@item.quantity - 1}
class="cart-qty-btn"
aria-label={"Decrease quantity of #{@item.name}"}
>
</button>
<span class="cart-qty-display">
{@item.quantity}
</span>
<button
type="submit"
name="quantity"
value={@item.quantity + 1}
class="cart-qty-btn"
aria-label={"Increase quantity of #{@item.name}"}
>
+
</button>
</form>
<% else %>
<span class="cart-qty-text">
Qty: {@item.quantity}
</span>
<% end %>
<.cart_remove_button variant_id={@item.variant_id} item_name={@item.name} />
</div>
</div>
<div class="cart-item-price-col">
<p class="cart-item-price" data-size={if @size == :compact, do: "compact"}>
{Berrypod.Cart.format_price(@item.price * @item.quantity)}
</p>
</div>
</div>
"""
end
@doc """
Cart empty state component.
"""
attr :mode, :atom, default: :live
def cart_empty_state(assigns) do
~H"""
<div class="cart-empty">
<svg
class="cart-empty-icon"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
<line x1="3" y1="6" x2="21" y2="6"></line>
<path d="M16 10a4 4 0 01-8 0"></path>
</svg>
<p>Your basket is empty</p>
<%= if @mode == :preview do %>
<button
type="button"
phx-click="change_preview_page"
phx-value-page="collection"
class="cart-continue-link"
>
Continue shopping
</button>
<% else %>
<.link
navigate="/collections/all"
class="cart-continue-link"
>
Continue shopping
</.link>
<% end %>
</div>
"""
end
@doc """
Remove button for cart items.
"""
attr :variant_id, :string, required: true
attr :item_name, :string, default: "item"
def cart_remove_button(assigns) do
~H"""
<form action="/cart/remove" method="post" phx-submit="remove_item_form" class="cart-remove-form">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input type="hidden" name="variant_id" value={@variant_id} />
<button
type="submit"
class="cart-remove-btn"
aria-label={"Remove #{@item_name} from cart"}
>
Remove
</button>
</form>
"""
end
@doc """
Renders a cart item row.
## Attributes
* `item` - Required. Map with `product` (containing `image_url`, `name`, `price`), `variant`, and `quantity`.
* `currency` - Optional. Currency symbol. Defaults to "£".
## Examples
<.cart_item item={item} />
"""
attr :item, :map, required: true
def cart_item(assigns) do
~H"""
<.shop_card class="cart-page-item">
<div class="cart-page-image">
<img
src={cart_item_image(@item.product)}
alt={@item.product.title}
width="96"
height="96"
loading="lazy"
/>
</div>
<div class="cart-page-item-info">
<h3 class="cart-page-item-name">
{@item.product.title}
</h3>
<p class="cart-page-item-variant">
{@item.variant}
</p>
<div class="cart-page-item-actions">
<div class="cart-qty-group">
<button class="cart-qty-btn"></button>
<span class="cart-qty-display">
{@item.quantity}
</span>
<button class="cart-qty-btn">+</button>
</div>
<button class="cart-page-item-remove">
Remove
</button>
</div>
</div>
<div class="cart-page-item-price-col">
<p class="cart-page-item-price">
{Berrypod.Cart.format_price(@item.product.cheapest_price * @item.quantity)}
</p>
</div>
</.shop_card>
"""
end
defp cart_item_image(product) do
ProductImage.url(Product.primary_image(product), 400)
end
# Shared delivery line used by both cart_drawer and order_summary.
# Shows a country <select> when rates are available, falls back to plain text.
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
defp delivery_line(assigns) do
~H"""
<div class="delivery-line">
<span class="delivery-line-label">
Delivery to
<%= if @available_countries != [] and @mode != :preview do %>
<form phx-change="change_country">
<select
name="country"
class="delivery-select"
aria-label="Delivery country"
>
<%= for {code, name} <- @available_countries do %>
<option value={code} selected={code == @country_code}>{name}</option>
<% end %>
</select>
</form>
<% else %>
<span>{Berrypod.Shipping.country_name(@country_code)}</span>
<% end %>
</span>
<%= if @shipping_estimate do %>
<span>{Berrypod.Cart.format_price(@shipping_estimate)}</span>
<% else %>
<span>Calculated at checkout</span>
<% end %>
</div>
"""
end
@doc """
Renders the order summary card.
## Attributes
* `subtotal` - Required. Subtotal amount (in pence/cents).
* `shipping_estimate` - Optional. Shipping estimate in pence.
* `country_code` - Optional. Current country code. Default "GB".
* `available_countries` - Optional. List of `{code, name}` tuples.
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.order_summary subtotal={3600} />
"""
attr :subtotal, :integer, required: true
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
def order_summary(assigns) do
assigns =
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
~H"""
<.shop_card class="order-summary-card">
<h2 class="order-summary-heading">
Order summary
</h2>
<div class="order-summary-lines">
<div class="order-summary-line">
<span>Subtotal</span>
<span>
{Berrypod.Cart.format_price(@subtotal)}
</span>
</div>
<.delivery_line
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
mode={@mode}
/>
<div class="order-summary-divider">
<div class="order-summary-total">
<span>
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
</span>
<span>
{Berrypod.Cart.format_price(@estimated_total)}
</span>
</div>
</div>
</div>
<%= if @mode == :preview do %>
<.shop_button class="order-summary-checkout">
Checkout
</.shop_button>
<.shop_button_outline
phx-click="change_preview_page"
phx-value-page="collection"
class="order-summary-continue"
>
Continue shopping
</.shop_button_outline>
<% else %>
<form action="/checkout" method="post" class="order-summary-checkout-form">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_button type="submit" class="order-summary-checkout">
Checkout
</.shop_button>
</form>
<.shop_link_outline
href="/collections/all"
class="order-summary-continue"
>
Continue shopping
</.shop_link_outline>
<% end %>
</.shop_card>
"""
end
@doc """
Renders a cart items list with order summary layout.
## Attributes
* `items` - Required. List of cart items.
* `subtotal` - Required. Subtotal in pence/cents.
* `currency` - Optional. Currency symbol. Defaults to "£".
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.cart_layout items={@cart_items} subtotal={3600} mode={:preview} />
"""
attr :items, :list, required: true
attr :subtotal, :integer, required: true
attr :mode, :atom, default: :live
def cart_layout(assigns) do
~H"""
<div class="cart-layout">
<div class="cart-items-stack">
<%= for item <- @items do %>
<.cart_item item={item} />
<% end %>
</div>
<div>
<.order_summary subtotal={@subtotal} mode={@mode} />
</div>
</div>
"""
end
end