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

@ -462,6 +462,29 @@
text-decoration: underline;
}
.collection-sort-submit {
padding: 0.375rem 1rem;
font-size: var(--t-text-small);
}
/* ── Search page ── */
.search-page-form {
display: flex;
gap: 0.75rem;
margin-bottom: 2rem;
max-width: 32rem;
}
.search-page-form input {
flex: 1;
}
.search-page-count {
color: var(--t-text-secondary);
margin-bottom: 1.5rem;
}
/* ── Breadcrumb ── */
.breadcrumb {
@ -1414,6 +1437,10 @@
text-decoration: underline;
}
.cart-remove-form {
display: contents;
}
/* ── Cart empty state ── */
.cart-empty {

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)

View File

@ -4,6 +4,11 @@ defmodule Berrypod.Analytics.RetentionWorkerTest do
alias Berrypod.Analytics.{Event, RetentionWorker}
alias Berrypod.Repo
setup do
Repo.delete_all(Event)
:ok
end
test "deletes events older than 12 months" do
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
recent = DateTime.utc_now() |> DateTime.truncate(:second)

View File

@ -0,0 +1,111 @@
defmodule BerrypodWeb.CartControllerTest do
use BerrypodWeb.ConnCase, async: false
import Berrypod.AccountsFixtures
alias Berrypod.ProductsFixtures
setup do
user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
:ok
end
defp create_variant(_context) do
product = ProductsFixtures.complete_product_fixture(%{title: "Test Print"})
variant = List.first(product.variants)
%{product: product, variant: variant}
end
defp conn_with_cart(conn, variant_id, qty) do
Phoenix.ConnTest.init_test_session(conn, %{"cart" => [{variant_id, qty}]})
end
describe "POST /cart/add" do
setup [:create_variant]
test "adds item to session cart and redirects to /cart", %{conn: conn, variant: variant} do
conn = post(conn, ~p"/cart/add", %{"variant_id" => variant.id, "quantity" => "1"})
assert redirected_to(conn) == "/cart"
assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Added to basket"
cart = get_session(conn, "cart")
assert [{variant_id, 1}] = cart
assert variant_id == variant.id
end
test "increments quantity if item already in cart", %{conn: conn, variant: variant} do
conn =
conn
|> conn_with_cart(variant.id, 2)
|> post(~p"/cart/add", %{"variant_id" => variant.id, "quantity" => "3"})
cart = get_session(conn, "cart")
assert [{_, 5}] = cart
end
test "defaults quantity to 1 for invalid values", %{conn: conn, variant: variant} do
conn = post(conn, ~p"/cart/add", %{"variant_id" => variant.id, "quantity" => "abc"})
cart = get_session(conn, "cart")
assert [{_, 1}] = cart
end
test "handles missing params gracefully", %{conn: conn} do
conn = post(conn, ~p"/cart/add", %{})
assert redirected_to(conn) == "/cart"
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Could not add item to basket"
end
end
describe "POST /cart/remove" do
setup [:create_variant]
test "removes item from session cart", %{conn: conn, variant: variant} do
conn =
conn
|> conn_with_cart(variant.id, 2)
|> post(~p"/cart/remove", %{"variant_id" => variant.id})
assert redirected_to(conn) == "/cart"
assert Phoenix.Flash.get(conn.assigns.flash, :info) == "Removed from basket"
cart = get_session(conn, "cart")
assert cart == []
end
test "handles removing non-existent item", %{conn: conn} do
conn = post(conn, ~p"/cart/remove", %{"variant_id" => Ecto.UUID.generate()})
assert redirected_to(conn) == "/cart"
end
end
describe "POST /cart/update" do
setup [:create_variant]
test "updates quantity in session cart", %{conn: conn, variant: variant} do
conn =
conn
|> conn_with_cart(variant.id, 1)
|> post(~p"/cart/update", %{"variant_id" => variant.id, "quantity" => "5"})
assert redirected_to(conn) == "/cart"
cart = get_session(conn, "cart")
assert [{_, 5}] = cart
end
test "removes item when quantity is 0 or less", %{conn: conn, variant: variant} do
conn =
conn
|> conn_with_cart(variant.id, 3)
|> post(~p"/cart/update", %{"variant_id" => variant.id, "quantity" => "0"})
cart = get_session(conn, "cart")
assert cart == []
end
end
end

View File

@ -10,6 +10,7 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do
setup do
send(Buffer, :flush)
:timer.sleep(50)
Repo.delete_all(Event)
user = user_fixture()
%{user: user}

View File

@ -68,41 +68,39 @@ defmodule BerrypodWeb.Shop.CartTest do
test "incrementing quantity updates the display", %{
conn: conn,
product: product,
variant: variant
} do
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
html =
view
|> element("#main-content button[aria-label='Increase quantity of #{product.title}']")
|> render_click()
|> form("#main-content form[phx-submit='update_quantity_form']")
|> render_submit(%{"quantity" => "2"})
assert html =~ "Quantity updated to 2"
end
test "decrementing to zero removes the item", %{
conn: conn,
product: product,
variant: variant
} do
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
html =
view
|> element("#main-content button[aria-label='Decrease quantity of #{product.title}']")
|> render_click()
|> form("#main-content form[phx-submit='update_quantity_form']")
|> render_submit(%{"quantity" => "0"})
assert html =~ "Your basket is empty"
end
test "remove button removes the item", %{conn: conn, product: product, variant: variant} do
test "remove button removes the item", %{conn: conn, variant: variant} do
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
html =
view
|> element("#main-content button[aria-label='Remove #{product.title} from cart']")
|> render_click()
|> form("#main-content form[phx-submit='remove_item_form']")
|> render_submit()
assert html =~ "Your basket is empty"
end

View File

@ -292,11 +292,11 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
view |> element("button[phx-click='increment_quantity']") |> render_click()
view |> element("button[phx-click='increment_quantity']") |> render_click()
# Add to cart
# Add to cart via form submit
html =
view
|> element("button", "Add to basket")
|> render_click()
|> form("form[phx-submit='add_to_cart']")
|> render_submit()
# Decrement should be disabled again (quantity reset to 1)
assert html =~ ~s(phx-click="decrement_quantity")
@ -310,8 +310,8 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
html =
view
|> element("button", "Add to basket")
|> render_click()
|> form("form[phx-submit='add_to_cart']")
|> render_submit()
assert html =~ "added to cart"
end
@ -321,8 +321,8 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
html =
view
|> element("button", "Add to basket")
|> render_click()
|> form("form[phx-submit='add_to_cart']")
|> render_submit()
assert html =~ "Mountain Sunrise Print"
end

View File

@ -0,0 +1,57 @@
defmodule BerrypodWeb.Shop.SearchPageTest do
use BerrypodWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import Berrypod.AccountsFixtures
alias Berrypod.ProductsFixtures
setup do
user_fixture()
{:ok, _} = Berrypod.Settings.set_site_live(true)
:ok
end
describe "GET /search" do
test "renders search page with empty query", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/search")
assert html =~ "Search"
assert html =~ ~s(placeholder="Search products...")
end
test "renders search results for a matching query", %{conn: conn} do
product =
ProductsFixtures.complete_product_fixture(%{title: "Blue Mountain Print"})
Berrypod.Search.index_product(product)
{:ok, _view, html} = live(conn, ~p"/search?q=mountain")
assert html =~ "Blue Mountain Print"
assert html =~ "1 result"
end
test "renders no-results message for unmatched query", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/search?q=nonexistent")
assert html =~ "No products found"
end
test "search form submits via LiveView and updates URL", %{conn: conn} do
product =
ProductsFixtures.complete_product_fixture(%{title: "Red Sunset Poster"})
Berrypod.Search.index_product(product)
{:ok, view, _html} = live(conn, ~p"/search")
html =
view
|> form(".search-page-form", %{"q" => "sunset"})
|> render_submit()
assert html =~ "Red Sunset Poster"
end
end
end