add no-JS progressive enhancement for all shop flows
All checks were successful
deploy / deploy (push) Successful in 1m23s
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:
parent
f788108665
commit
0b0adba0fe
@ -462,6 +462,29 @@
|
|||||||
text-decoration: underline;
|
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 ── */
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
@ -1414,6 +1437,10 @@
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-remove-form {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Cart empty state ── */
|
/* ── Cart empty state ── */
|
||||||
|
|
||||||
.cart-empty {
|
.cart-empty {
|
||||||
|
|||||||
@ -106,6 +106,34 @@ defmodule BerrypodWeb.CartHook do
|
|||||||
{:halt, socket}
|
{:halt, socket}
|
||||||
end
|
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}
|
defp handle_cart_event(_event, _params, socket), do: {:cont, socket}
|
||||||
|
|
||||||
# Shared info handlers
|
# Shared info handlers
|
||||||
|
|||||||
@ -25,6 +25,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<.product_info product={@product} display_price={@display_price} />
|
<.product_info product={@product} display_price={@display_price} />
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="quantity" value={@quantity} />
|
||||||
|
|
||||||
<%!-- Dynamic variant selectors --%>
|
<%!-- Dynamic variant selectors --%>
|
||||||
<%= for option_type <- @option_types do %>
|
<%= for option_type <- @option_types do %>
|
||||||
<.variant_selector
|
<.variant_selector
|
||||||
@ -45,6 +54,7 @@
|
|||||||
|
|
||||||
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
|
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
|
||||||
<.add_to_cart_button mode={@mode} />
|
<.add_to_cart_button mode={@mode} />
|
||||||
|
</form>
|
||||||
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
|
||||||
<.product_details product={@product} />
|
<.product_details product={@product} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -213,11 +213,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
|
|
||||||
<div class="cart-item-actions">
|
<div class="cart-item-actions">
|
||||||
<%= if @show_quantity_controls do %>
|
<%= 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
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
phx-click="decrement"
|
name="quantity"
|
||||||
phx-value-id={@item.variant_id}
|
value={@item.quantity - 1}
|
||||||
class="cart-qty-btn"
|
class="cart-qty-btn"
|
||||||
aria-label={"Decrease quantity of #{@item.name}"}
|
aria-label={"Decrease quantity of #{@item.name}"}
|
||||||
>
|
>
|
||||||
@ -227,15 +234,15 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
{@item.quantity}
|
{@item.quantity}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
phx-click="increment"
|
name="quantity"
|
||||||
phx-value-id={@item.variant_id}
|
value={@item.quantity + 1}
|
||||||
class="cart-qty-btn"
|
class="cart-qty-btn"
|
||||||
aria-label={"Increase quantity of #{@item.name}"}
|
aria-label={"Increase quantity of #{@item.name}"}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="cart-qty-text">
|
<span class="cart-qty-text">
|
||||||
Qty: {@item.quantity}
|
Qty: {@item.quantity}
|
||||||
@ -306,15 +313,17 @@ defmodule BerrypodWeb.ShopComponents.Cart do
|
|||||||
|
|
||||||
def cart_remove_button(assigns) do
|
def cart_remove_button(assigns) do
|
||||||
~H"""
|
~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
|
<button
|
||||||
type="button"
|
type="submit"
|
||||||
phx-click="remove_item"
|
|
||||||
phx-value-id={@variant_id}
|
|
||||||
class="cart-remove-btn"
|
class="cart-remove-btn"
|
||||||
aria-label={"Remove #{@item_name} from cart"}
|
aria-label={"Remove #{@item_name} from cart"}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -755,10 +755,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</.link>
|
</.link>
|
||||||
<button
|
<a
|
||||||
type="button"
|
href="/search"
|
||||||
class="header-icon-btn"
|
|
||||||
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
|
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
|
||||||
|
class="header-icon-btn"
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -772,11 +772,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<circle cx="11" cy="11" r="8"></circle>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<path d="M21 21l-4.35-4.35"></path>
|
<path d="M21 21l-4.35-4.35"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</a>
|
||||||
<button
|
<a
|
||||||
type="button"
|
href="/cart"
|
||||||
class="header-icon-btn"
|
|
||||||
phx-click={open_cart_drawer_js()}
|
phx-click={open_cart_drawer_js()}
|
||||||
|
class="header-icon-btn"
|
||||||
aria-label="Cart"
|
aria-label="Cart"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -797,7 +797,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<span class="sr-only">Cart ({@cart_count})</span>
|
<span class="sr-only">Cart ({@cart_count})</span>
|
||||||
</button>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1525,8 +1525,8 @@ defmodule BerrypodWeb.ShopComponents.Product do
|
|||||||
~H"""
|
~H"""
|
||||||
<div class="atc-wrap" data-sticky={to_string(@sticky)}>
|
<div class="atc-wrap" data-sticky={to_string(@sticky)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type={if @mode == :live, do: "submit", else: "button"}
|
||||||
phx-click={if @mode == :preview, do: open_cart_drawer_js(), else: "add_to_cart"}
|
phx-click={if @mode == :preview, do: open_cart_drawer_js()}
|
||||||
disabled={@disabled}
|
disabled={@disabled}
|
||||||
class="atc-btn"
|
class="atc-btn"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
defmodule BerrypodWeb.CartController do
|
defmodule BerrypodWeb.CartController do
|
||||||
@moduledoc """
|
@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
|
The JSON `update/2` action is called by a JS hook after each LiveView cart
|
||||||
via this API endpoint called from a JS hook after each cart modification.
|
change. The HTML actions (`add/2`, `remove/2`, `update_item/2`) provide
|
||||||
|
no-JS fallbacks via plain form POST + redirect.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use BerrypodWeb, :controller
|
use BerrypodWeb, :controller
|
||||||
@ -11,9 +12,7 @@ defmodule BerrypodWeb.CartController do
|
|||||||
alias Berrypod.Cart
|
alias Berrypod.Cart
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Updates the cart in session.
|
Updates the cart in session (JSON API for JS hook).
|
||||||
|
|
||||||
Expects JSON body with `items` as a list of [variant_id, quantity] arrays.
|
|
||||||
"""
|
"""
|
||||||
def update(conn, %{"items" => items}) when is_list(items) do
|
def update(conn, %{"items" => items}) when is_list(items) do
|
||||||
cart_items = Cart.deserialize(items)
|
cart_items = Cart.deserialize(items)
|
||||||
@ -28,4 +27,69 @@ defmodule BerrypodWeb.CartController do
|
|||||||
|> put_status(:bad_request)
|
|> put_status(:bad_request)
|
||||||
|> json(%{error: "Invalid cart data"})
|
|> json(%{error: "Invalid cart data"})
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -176,13 +176,16 @@ defmodule BerrypodWeb.Shop.Collection do
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<form phx-change="sort_changed">
|
<form action={~p"/collections/#{@current_slug || "all"}"} method="get" phx-change="sort_changed">
|
||||||
<.shop_select
|
<.shop_select
|
||||||
name="sort"
|
name="sort"
|
||||||
options={@sort_options}
|
options={@sort_options}
|
||||||
selected={@current_sort}
|
selected={@current_sort}
|
||||||
aria-label="Sort products"
|
aria-label="Sort products"
|
||||||
/>
|
/>
|
||||||
|
<noscript>
|
||||||
|
<button type="submit" class="themed-button collection-sort-submit">Sort</button>
|
||||||
|
</noscript>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|||||||
75
lib/berrypod_web/live/shop/search.ex
Normal file
75
lib/berrypod_web/live/shop/search.ex
Normal 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
|
||||||
@ -81,6 +81,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
live "/collections/:slug", Shop.Collection, :show
|
live "/collections/:slug", Shop.Collection, :show
|
||||||
live "/products/:id", Shop.ProductShow, :show
|
live "/products/:id", Shop.ProductShow, :show
|
||||||
live "/cart", Shop.Cart, :index
|
live "/cart", Shop.Cart, :index
|
||||||
|
live "/search", Shop.Search, :index
|
||||||
live "/checkout/success", Shop.CheckoutSuccess, :show
|
live "/checkout/success", Shop.CheckoutSuccess, :show
|
||||||
live "/orders", Shop.Orders, :index
|
live "/orders", Shop.Orders, :index
|
||||||
live "/orders/:order_number", Shop.OrderDetail, :show
|
live "/orders/:order_number", Shop.OrderDetail, :show
|
||||||
@ -88,6 +89,11 @@ defmodule BerrypodWeb.Router do
|
|||||||
|
|
||||||
# Checkout (POST — creates Stripe session and redirects)
|
# Checkout (POST — creates Stripe session and redirects)
|
||||||
post "/checkout", CheckoutController, :create
|
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
|
end
|
||||||
|
|
||||||
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
|
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
|
||||||
|
|||||||
@ -4,6 +4,11 @@ defmodule Berrypod.Analytics.RetentionWorkerTest do
|
|||||||
alias Berrypod.Analytics.{Event, RetentionWorker}
|
alias Berrypod.Analytics.{Event, RetentionWorker}
|
||||||
alias Berrypod.Repo
|
alias Berrypod.Repo
|
||||||
|
|
||||||
|
setup do
|
||||||
|
Repo.delete_all(Event)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
test "deletes events older than 12 months" do
|
test "deletes events older than 12 months" do
|
||||||
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
|
old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
|
||||||
recent = DateTime.utc_now() |> DateTime.truncate(:second)
|
recent = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|||||||
111
test/berrypod_web/controllers/cart_controller_test.exs
Normal file
111
test/berrypod_web/controllers/cart_controller_test.exs
Normal 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
|
||||||
@ -10,6 +10,7 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do
|
|||||||
setup do
|
setup do
|
||||||
send(Buffer, :flush)
|
send(Buffer, :flush)
|
||||||
:timer.sleep(50)
|
:timer.sleep(50)
|
||||||
|
Repo.delete_all(Event)
|
||||||
|
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
%{user: user}
|
%{user: user}
|
||||||
|
|||||||
@ -68,41 +68,39 @@ defmodule BerrypodWeb.Shop.CartTest do
|
|||||||
|
|
||||||
test "incrementing quantity updates the display", %{
|
test "incrementing quantity updates the display", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
product: product,
|
|
||||||
variant: variant
|
variant: variant
|
||||||
} do
|
} do
|
||||||
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
|
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("#main-content button[aria-label='Increase quantity of #{product.title}']")
|
|> form("#main-content form[phx-submit='update_quantity_form']")
|
||||||
|> render_click()
|
|> render_submit(%{"quantity" => "2"})
|
||||||
|
|
||||||
assert html =~ "Quantity updated to 2"
|
assert html =~ "Quantity updated to 2"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "decrementing to zero removes the item", %{
|
test "decrementing to zero removes the item", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
product: product,
|
|
||||||
variant: variant
|
variant: variant
|
||||||
} do
|
} do
|
||||||
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
|
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("#main-content button[aria-label='Decrease quantity of #{product.title}']")
|
|> form("#main-content form[phx-submit='update_quantity_form']")
|
||||||
|> render_click()
|
|> render_submit(%{"quantity" => "0"})
|
||||||
|
|
||||||
assert html =~ "Your basket is empty"
|
assert html =~ "Your basket is empty"
|
||||||
end
|
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")
|
{:ok, view, _html} = conn |> conn_with_cart(variant.id) |> live(~p"/cart")
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("#main-content button[aria-label='Remove #{product.title} from cart']")
|
|> form("#main-content form[phx-submit='remove_item_form']")
|
||||||
|> render_click()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "Your basket is empty"
|
assert html =~ "Your basket is empty"
|
||||||
end
|
end
|
||||||
|
|||||||
@ -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()
|
||||||
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 =
|
html =
|
||||||
view
|
view
|
||||||
|> element("button", "Add to basket")
|
|> form("form[phx-submit='add_to_cart']")
|
||||||
|> render_click()
|
|> render_submit()
|
||||||
|
|
||||||
# Decrement should be disabled again (quantity reset to 1)
|
# Decrement should be disabled again (quantity reset to 1)
|
||||||
assert html =~ ~s(phx-click="decrement_quantity")
|
assert html =~ ~s(phx-click="decrement_quantity")
|
||||||
@ -310,8 +310,8 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
|
|||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("button", "Add to basket")
|
|> form("form[phx-submit='add_to_cart']")
|
||||||
|> render_click()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "added to cart"
|
assert html =~ "added to cart"
|
||||||
end
|
end
|
||||||
@ -321,8 +321,8 @@ defmodule BerrypodWeb.Shop.ProductShowTest do
|
|||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("button", "Add to basket")
|
|> form("form[phx-submit='add_to_cart']")
|
||||||
|> render_click()
|
|> render_submit()
|
||||||
|
|
||||||
assert html =~ "Mountain Sunrise Print"
|
assert html =~ "Mountain Sunrise Print"
|
||||||
end
|
end
|
||||||
|
|||||||
57
test/berrypod_web/live/shop/search_page_test.exs
Normal file
57
test/berrypod_web/live/shop/search_page_test.exs
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user