rename project from SimpleshopTheme to Berrypod
All modules, configs, paths, and references updated. 836 tests pass, zero warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
539
lib/berrypod_web/components/shop_components/cart.ex
Normal file
539
lib/berrypod_web/components/shop_components/cart.ex
Normal file
@@ -0,0 +1,539 @@
|
||||
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 %>
|
||||
<div class="cart-qty-group">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="decrement"
|
||||
phx-value-id={@item.variant_id}
|
||||
class="cart-qty-btn"
|
||||
aria-label={"Decrease quantity of #{@item.name}"}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="cart-qty-display">
|
||||
{@item.quantity}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="increment"
|
||||
phx-value-id={@item.variant_id}
|
||||
class="cart-qty-btn"
|
||||
aria-label={"Increase quantity of #{@item.name}"}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<% 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"""
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_item"
|
||||
phx-value-id={@variant_id}
|
||||
class="cart-remove-btn"
|
||||
aria-label={"Remove #{@item_name} from cart"}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user