add no-JS progressive enhancement for all shop flows
All checks were successful
deploy / deploy (push) Successful in 1m23s

Every key shop flow now works via plain HTML forms when JS is
unavailable. LiveView progressively enhances when JS connects.

- PDP: form wraps variant/qty/add-to-cart with action="/cart/add"
- Cart page: qty +/- and remove use form POST fallbacks
- Cart/search header icons are now links with phx-click enhancement
- Collection sort form has GET action + noscript submit button
- New /search page with form-based search for no-JS users
- CartController gains add/remove/update_item POST actions
- CartHook gains update_quantity_form and remove_item_form handlers
- Fix flaky analytics tests caused by event table pollution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-24 22:56:19 +00:00
parent f788108665
commit 0b0adba0fe
16 changed files with 461 additions and 67 deletions

View File

@@ -106,6 +106,34 @@ defmodule BerrypodWeb.CartHook do
{:halt, socket}
end
defp handle_cart_event(
"update_quantity_form",
%{"variant_id" => id, "quantity" => qty_str},
socket
) do
quantity = String.to_integer(qty_str)
cart = Cart.update_quantity(socket.assigns.raw_cart, id, quantity)
new_qty = Cart.get_quantity(cart, id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Quantity updated to #{new_qty}")
{:halt, socket}
end
defp handle_cart_event("remove_item_form", %{"variant_id" => id}, socket) do
cart = Cart.remove_item(socket.assigns.raw_cart, id)
socket =
socket
|> broadcast_and_update(cart)
|> assign(:cart_status, "Item removed from cart")
{:halt, socket}
end
defp handle_cart_event(_event, _params, socket), do: {:cont, socket}
# Shared info handlers

View File

@@ -25,26 +25,36 @@
<div>
<.product_info product={@product} display_price={@display_price} />
<%!-- Dynamic variant selectors --%>
<%= for option_type <- @option_types do %>
<.variant_selector
option_type={option_type}
selected={@selected_options[option_type.name]}
available={@available_options[option_type.name] || []}
mode={@mode}
<form action="/cart/add" method="post" phx-submit="add_to_cart">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<input
type="hidden"
name="variant_id"
value={@selected_variant && @selected_variant.id}
/>
<% end %>
<input type="hidden" name="quantity" value={@quantity} />
<%!-- Fallback for products with no variant options --%>
<div
:if={@option_types == []}
class="pdp-variant-fallback"
>
One size
</div>
<%!-- Dynamic variant selectors --%>
<%= for option_type <- @option_types do %>
<.variant_selector
option_type={option_type}
selected={@selected_options[option_type.name]}
available={@available_options[option_type.name] || []}
mode={@mode}
/>
<% end %>
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
<.add_to_cart_button mode={@mode} />
<%!-- Fallback for products with no variant options --%>
<div
:if={@option_types == []}
class="pdp-variant-fallback"
>
One size
</div>
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
<.add_to_cart_button mode={@mode} />
</form>
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
<.product_details product={@product} />
</div>

View File

@@ -213,11 +213,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do
<div class="cart-item-actions">
<%= if @show_quantity_controls do %>
<div class="cart-qty-group">
<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="button"
phx-click="decrement"
phx-value-id={@item.variant_id}
type="submit"
name="quantity"
value={@item.quantity - 1}
class="cart-qty-btn"
aria-label={"Decrease quantity of #{@item.name}"}
>
@@ -227,15 +234,15 @@ defmodule BerrypodWeb.ShopComponents.Cart do
{@item.quantity}
</span>
<button
type="button"
phx-click="increment"
phx-value-id={@item.variant_id}
type="submit"
name="quantity"
value={@item.quantity + 1}
class="cart-qty-btn"
aria-label={"Increase quantity of #{@item.name}"}
>
+
</button>
</div>
</form>
<% else %>
<span class="cart-qty-text">
Qty: {@item.quantity}
@@ -306,15 +313,17 @@ defmodule BerrypodWeb.ShopComponents.Cart do
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>
<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

View File

@@ -755,10 +755,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do
/>
</svg>
</.link>
<button
type="button"
class="header-icon-btn"
<a
href="/search"
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
class="header-icon-btn"
aria-label="Search"
>
<svg
@@ -772,11 +772,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button>
<button
type="button"
class="header-icon-btn"
</a>
<a
href="/cart"
phx-click={open_cart_drawer_js()}
class="header-icon-btn"
aria-label="Cart"
>
<svg
@@ -797,7 +797,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</span>
<% end %>
<span class="sr-only">Cart ({@cart_count})</span>
</button>
</a>
</div>
</header>
"""

View File

@@ -1525,8 +1525,8 @@ defmodule BerrypodWeb.ShopComponents.Product do
~H"""
<div class="atc-wrap" data-sticky={to_string(@sticky)}>
<button
type="button"
phx-click={if @mode == :preview, do: open_cart_drawer_js(), else: "add_to_cart"}
type={if @mode == :live, do: "submit", else: "button"}
phx-click={if @mode == :preview, do: open_cart_drawer_js()}
disabled={@disabled}
class="atc-btn"
>

View File

@@ -1,9 +1,10 @@
defmodule BerrypodWeb.CartController do
@moduledoc """
API controller for cart session persistence.
Cart controller handling both JSON API persistence and HTML form fallbacks.
LiveView cannot write to session directly, so cart updates are persisted
via this API endpoint called from a JS hook after each cart modification.
The JSON `update/2` action is called by a JS hook after each LiveView cart
change. The HTML actions (`add/2`, `remove/2`, `update_item/2`) provide
no-JS fallbacks via plain form POST + redirect.
"""
use BerrypodWeb, :controller
@@ -11,9 +12,7 @@ defmodule BerrypodWeb.CartController do
alias Berrypod.Cart
@doc """
Updates the cart in session.
Expects JSON body with `items` as a list of [variant_id, quantity] arrays.
Updates the cart in session (JSON API for JS hook).
"""
def update(conn, %{"items" => items}) when is_list(items) do
cart_items = Cart.deserialize(items)
@@ -28,4 +27,69 @@ defmodule BerrypodWeb.CartController do
|> put_status(:bad_request)
|> json(%{error: "Invalid cart data"})
end
@doc """
Adds an item to cart via form POST (no-JS fallback).
"""
def add(conn, %{"variant_id" => variant_id, "quantity" => qty_str}) do
quantity = parse_quantity(qty_str)
cart = Cart.get_from_session(get_session(conn))
cart = Cart.add_item(cart, variant_id, quantity)
conn
|> Cart.put_in_session(cart)
|> put_flash(:info, "Added to basket")
|> redirect(to: ~p"/cart")
end
def add(conn, _params) do
conn
|> put_flash(:error, "Could not add item to basket")
|> redirect(to: ~p"/cart")
end
@doc """
Removes an item from cart via form POST (no-JS fallback).
"""
def remove(conn, %{"variant_id" => variant_id}) do
cart = Cart.get_from_session(get_session(conn))
cart = Cart.remove_item(cart, variant_id)
conn
|> Cart.put_in_session(cart)
|> put_flash(:info, "Removed from basket")
|> redirect(to: ~p"/cart")
end
@doc """
Updates item quantity via form POST (no-JS fallback).
"""
def update_item(conn, %{"variant_id" => variant_id, "quantity" => qty_str}) do
quantity = parse_update_quantity(qty_str)
cart = Cart.get_from_session(get_session(conn))
cart = Cart.update_quantity(cart, variant_id, quantity)
conn
|> Cart.put_in_session(cart)
|> redirect(to: ~p"/cart")
end
defp parse_quantity(str) when is_binary(str) do
case Integer.parse(str) do
{qty, _} when qty > 0 -> qty
_ -> 1
end
end
defp parse_quantity(_), do: 1
# Allows 0 and negative values so Cart.update_quantity can remove items
defp parse_update_quantity(str) when is_binary(str) do
case Integer.parse(str) do
{qty, _} -> qty
:error -> 1
end
end
defp parse_update_quantity(_), do: 1
end

View File

@@ -176,13 +176,16 @@ defmodule BerrypodWeb.Shop.Collection do
</ul>
</nav>
<form phx-change="sort_changed">
<form action={~p"/collections/#{@current_slug || "all"}"} method="get" phx-change="sort_changed">
<.shop_select
name="sort"
options={@sort_options}
selected={@current_sort}
aria-label="Sort products"
/>
<noscript>
<button type="submit" class="themed-button collection-sort-submit">Sort</button>
</noscript>
</form>
</div>
"""

View File

@@ -0,0 +1,75 @@
defmodule BerrypodWeb.Shop.Search do
use BerrypodWeb, :live_view
alias Berrypod.Search
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "Search")}
end
@impl true
def handle_params(params, _uri, socket) do
query = params["q"] || ""
results = if query != "", do: Search.search(query), else: []
{:noreply,
socket
|> assign(:search_page_query, query)
|> assign(:search_page_results, results)}
end
@impl true
def handle_event("search_submit", %{"q" => query}, socket) do
{:noreply, push_patch(socket, to: ~p"/search?q=#{query}")}
end
@impl true
def render(assigns) do
~H"""
<.shop_layout {layout_assigns(assigns)} active_page="search">
<main id="main-content" class="page-container">
<.page_title text="Search" />
<form action="/search" method="get" phx-submit="search_submit" class="search-page-form">
<input
type="search"
name="q"
value={@search_page_query}
placeholder="Search products..."
class="themed-input"
/>
<button type="submit" class="themed-button">Search</button>
</form>
<%= if @search_page_results != [] do %>
<p class="search-page-count">
{length(@search_page_results)} {if length(@search_page_results) == 1,
do: "result",
else: "results"} for "{@search_page_query}"
</p>
<.product_grid theme_settings={@theme_settings}>
<%= for product <- @search_page_results do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:default}
/>
<% end %>
</.product_grid>
<% else %>
<%= if @search_page_query != "" do %>
<div class="collection-empty">
<p>No products found for "{@search_page_query}"</p>
<.link navigate="/collections/all" class="collection-empty-link">
Browse all products
</.link>
</div>
<% end %>
<% end %>
</main>
</.shop_layout>
"""
end
end

View File

@@ -81,6 +81,7 @@ defmodule BerrypodWeb.Router do
live "/collections/:slug", Shop.Collection, :show
live "/products/:id", Shop.ProductShow, :show
live "/cart", Shop.Cart, :index
live "/search", Shop.Search, :index
live "/checkout/success", Shop.CheckoutSuccess, :show
live "/orders", Shop.Orders, :index
live "/orders/:order_number", Shop.OrderDetail, :show
@@ -88,6 +89,11 @@ defmodule BerrypodWeb.Router do
# Checkout (POST — creates Stripe session and redirects)
post "/checkout", CheckoutController, :create
# Cart form actions (no-JS fallbacks for LiveView cart events)
post "/cart/add", CartController, :add
post "/cart/remove", CartController, :remove
post "/cart/update", CartController, :update_item
end
# Health check (no auth, no theme loading — for load balancers and uptime monitors)