All checks were successful
deploy / deploy (push) Successful in 1m26s
Disable checkout when Stripe isn't connected (cart drawer, cart page, and early guard in checkout controller to prevent orphaned orders). Show amber warning on order detail when email isn't configured. Fix pre-existing missing vertical spacing between page blocks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
567 lines
17 KiB
Elixir
567 lines
17 KiB
Elixir
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
|
||
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>
|
||
<%= 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>
|
||
<% 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
|
||
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 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
|
||
|
||
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>
|
||
<% 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
|