berrypod/lib/berrypod_web/components/shop_components/cart.ex
jamey d3fe6f4b56 add provider sync enhancements for product lifecycle
- add discontinued status to products (soft-delete when removed from provider)
- add availability helpers to variants (available/out_of_stock/discontinued)
- add detailed sync audit logging (product created/updated/discontinued)
- add cost change detection with threshold alerts (5% warning, 20% critical)
- update cart to show unavailable items with appropriate messaging
- block checkout when cart contains unavailable items
- show discontinued badge on product pages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-29 18:49:55 +01:00

604 lines
18 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: []
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