diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index f245346..6b5abe4 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -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 { diff --git a/lib/berrypod_web/cart_hook.ex b/lib/berrypod_web/cart_hook.ex index ffbfd62..c21463a 100644 --- a/lib/berrypod_web/cart_hook.ex +++ b/lib/berrypod_web/cart_hook.ex @@ -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 diff --git a/lib/berrypod_web/components/page_templates/pdp.html.heex b/lib/berrypod_web/components/page_templates/pdp.html.heex index d96b780..7e82d79 100644 --- a/lib/berrypod_web/components/page_templates/pdp.html.heex +++ b/lib/berrypod_web/components/page_templates/pdp.html.heex @@ -25,26 +25,36 @@
<.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} +
+ + - <% end %> + - <%!-- Fallback for products with no variant options --%> -
- One size -
+ <%!-- 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 --%> +
+ One size +
+ + <.quantity_selector quantity={@quantity} in_stock={@product.in_stock} /> + <.add_to_cart_button mode={@mode} /> +
<.trust_badges :if={@theme_settings.pdp_trust_badges} /> <.product_details product={@product} />
diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex index 457dbbf..9db0c71 100644 --- a/lib/berrypod_web/components/shop_components/cart.ex +++ b/lib/berrypod_web/components/shop_components/cart.ex @@ -213,11 +213,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do
<%= if @show_quantity_controls do %> -
+
+ + -
+ <% else %> Qty: {@item.quantity} @@ -306,15 +313,17 @@ defmodule BerrypodWeb.ShopComponents.Cart do def cart_remove_button(assigns) do ~H""" - +
+ + + +
""" end diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index 5ebae71..52086e8 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -755,10 +755,10 @@ defmodule BerrypodWeb.ShopComponents.Layout do /> - - +
""" diff --git a/lib/berrypod_web/components/shop_components/product.ex b/lib/berrypod_web/components/shop_components/product.ex index 9566d7c..25a3f69 100644 --- a/lib/berrypod_web/components/shop_components/product.ex +++ b/lib/berrypod_web/components/shop_components/product.ex @@ -1525,8 +1525,8 @@ defmodule BerrypodWeb.ShopComponents.Product do ~H"""
+
""" diff --git a/lib/berrypod_web/live/shop/search.ex b/lib/berrypod_web/live/shop/search.ex new file mode 100644 index 0000000..aac8597 --- /dev/null +++ b/lib/berrypod_web/live/shop/search.ex @@ -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"> +
+ <.page_title text="Search" /> + +
+ + +
+ + <%= if @search_page_results != [] do %> +

+ {length(@search_page_results)} {if length(@search_page_results) == 1, + do: "result", + else: "results"} for "{@search_page_query}" +

+ <.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 %> + + <% else %> + <%= if @search_page_query != "" do %> +
+

No products found for "{@search_page_query}"

+ <.link navigate="/collections/all" class="collection-empty-link"> + Browse all products + +
+ <% end %> + <% end %> +
+ + """ + end +end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 328a221..dfcf9d2 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -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) diff --git a/test/berrypod/analytics/retention_worker_test.exs b/test/berrypod/analytics/retention_worker_test.exs index 020bd8b..44b033f 100644 --- a/test/berrypod/analytics/retention_worker_test.exs +++ b/test/berrypod/analytics/retention_worker_test.exs @@ -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) diff --git a/test/berrypod_web/controllers/cart_controller_test.exs b/test/berrypod_web/controllers/cart_controller_test.exs new file mode 100644 index 0000000..b6839d7 --- /dev/null +++ b/test/berrypod_web/controllers/cart_controller_test.exs @@ -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 diff --git a/test/berrypod_web/live/admin/analytics_test.exs b/test/berrypod_web/live/admin/analytics_test.exs index 323d6a5..0038a36 100644 --- a/test/berrypod_web/live/admin/analytics_test.exs +++ b/test/berrypod_web/live/admin/analytics_test.exs @@ -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} diff --git a/test/berrypod_web/live/shop/cart_test.exs b/test/berrypod_web/live/shop/cart_test.exs index be38b18..2361722 100644 --- a/test/berrypod_web/live/shop/cart_test.exs +++ b/test/berrypod_web/live/shop/cart_test.exs @@ -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 diff --git a/test/berrypod_web/live/shop/product_show_test.exs b/test/berrypod_web/live/shop/product_show_test.exs index 296401f..4fd78e8 100644 --- a/test/berrypod_web/live/shop/product_show_test.exs +++ b/test/berrypod_web/live/shop/product_show_test.exs @@ -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 diff --git a/test/berrypod_web/live/shop/search_page_test.exs b/test/berrypod_web/live/shop/search_page_test.exs new file mode 100644 index 0000000..0106b3c --- /dev/null +++ b/test/berrypod_web/live/shop/search_page_test.exs @@ -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