berrypod/lib/berrypod_web/components/shop_components/cart.ex

604 lines
18 KiB
Elixir
Raw Permalink Normal View History

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: []
attr :stripe_connected, :boolean, default: true
def cart_drawer(assigns) do
# Check if any cart items are unavailable (out of stock or discontinued)
has_unavailable_items =
Enum.any?(assigns.cart_items, fn item ->
Map.get(item, :product_discontinued) == true or Map.get(item, :is_available) == false
end)
assigns =
assigns
|> assign_new(:display_total, fn ->
assigns.total || assigns.subtotal || "£0.00"
end)
|> assign(:has_unavailable_items, has_unavailable_items)
~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>
<%= cond do %>
<% @mode == :preview -> %>
<button type="button" class="cart-drawer-checkout">
Checkout
</button>
<% !@stripe_connected -> %>
<button type="button" disabled class="cart-drawer-checkout">
Checkout
</button>
<p class="cart-drawer-notice">Checkout isn't available yet.</p>
<% @has_unavailable_items -> %>
<button type="button" disabled class="cart-drawer-checkout">
Checkout
</button>
<p class="cart-drawer-notice">
Remove unavailable items to checkout.
</p>
<% true -> %>
<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
patch={"/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
patch={"/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 %>
<p :if={Map.get(@item, :product_discontinued)} class="cart-item-unavailable">
This product is no longer available
</p>
<p
:if={!Map.get(@item, :product_discontinued) && Map.get(@item, :is_available) == false}
class="cart-item-unavailable"
>
This option is currently out of stock
</p>
<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
patch="/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 action="/cart/country" method="post" phx-change="change_country">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<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>
<noscript>
<button type="submit" class="themed-button delivery-country-submit">Update</button>
</noscript>
</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
attr :stripe_connected, :boolean, default: true
attr :has_unavailable_items, :boolean, default: false
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>
<%= cond do %>
<% @mode == :preview -> %>
<.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>
<% !@stripe_connected -> %>
<.shop_button disabled class="order-summary-checkout">
Checkout
</.shop_button>
<p class="order-summary-notice">Checkout isn't available yet.</p>
<.shop_link_outline
href="/collections/all"
class="order-summary-continue"
>
Continue shopping
</.shop_link_outline>
<% @has_unavailable_items -> %>
<.shop_button disabled class="order-summary-checkout">
Checkout
</.shop_button>
<p class="order-summary-notice">Remove unavailable items to checkout.</p>
<.shop_link_outline
href="/collections/all"
class="order-summary-continue"
>
Continue shopping
</.shop_link_outline>
<% true -> %>
<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