- 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>
604 lines
18 KiB
Elixir
604 lines
18 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
|
||
# 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
|