Replace PreviewData indirection in all shop LiveViews with direct Products context queries. Home, collection, product detail and error pages now query the database. Categories loaded once in ThemeHook. Cart hydration no longer falls back to mock data. PreviewData kept only for the theme editor. Search modal gains keyboard navigation (arrow keys, Enter, Escape), Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate links, and 150ms debounce. SearchModal JS hook manages selection state and highlight. search.ex gets transaction safety on reindex and a public remove_product/1. 10 new integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
7.0 KiB
Elixir
222 lines
7.0 KiB
Elixir
defmodule SimpleshopThemeWeb.Shop.Collection do
|
|
use SimpleshopThemeWeb, :live_view
|
|
|
|
alias SimpleshopTheme.Products
|
|
|
|
@sort_options [
|
|
{"featured", "Featured"},
|
|
{"newest", "Newest"},
|
|
{"price_asc", "Price: Low to High"},
|
|
{"price_desc", "Price: High to Low"},
|
|
{"name_asc", "Name: A-Z"},
|
|
{"name_desc", "Name: Z-A"}
|
|
]
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
socket =
|
|
socket
|
|
|> assign(:sort_options, @sort_options)
|
|
|> assign(:current_sort, "featured")
|
|
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(%{"slug" => slug} = params, _uri, socket) do
|
|
sort = params["sort"] || "featured"
|
|
|
|
case load_collection(slug, sort) do
|
|
{:ok, title, category, products} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:page_title, title)
|
|
|> assign(:collection_title, title)
|
|
|> assign(:current_category, category)
|
|
|> assign(:current_sort, sort)
|
|
|> assign(:products, products)}
|
|
|
|
:not_found ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, "Collection not found")
|
|
|> push_navigate(to: ~p"/collections/all")}
|
|
end
|
|
end
|
|
|
|
defp load_collection("all", sort) do
|
|
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)}
|
|
end
|
|
|
|
defp load_collection("sale", sort) do
|
|
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)}
|
|
end
|
|
|
|
defp load_collection(slug, sort) do
|
|
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
|
|
nil ->
|
|
:not_found
|
|
|
|
category ->
|
|
products = Products.list_visible_products(category: category.name, sort: sort)
|
|
{:ok, category.name, category, products}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("sort_changed", %{"sort" => sort}, socket) do
|
|
slug =
|
|
case socket.assigns.current_category do
|
|
nil -> "all"
|
|
:sale -> "sale"
|
|
category -> category.slug
|
|
end
|
|
|
|
{:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")}
|
|
end
|
|
|
|
defp collection_path(slug, "featured"), do: ~p"/collections/#{slug}"
|
|
defp collection_path(slug, sort), do: ~p"/collections/#{slug}?sort=#{sort}"
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<.shop_layout
|
|
theme_settings={@theme_settings}
|
|
logo_image={@logo_image}
|
|
header_image={@header_image}
|
|
mode={@mode}
|
|
cart_items={@cart_items}
|
|
cart_count={@cart_count}
|
|
cart_subtotal={@cart_subtotal}
|
|
cart_drawer_open={@cart_drawer_open}
|
|
cart_status={assigns[:cart_status]}
|
|
active_page="collection"
|
|
search_query={assigns[:search_query] || ""}
|
|
search_results={assigns[:search_results] || []}
|
|
>
|
|
<main id="main-content">
|
|
<.collection_header
|
|
title={@collection_title}
|
|
product_count={length(@products)}
|
|
/>
|
|
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
<.collection_filter_bar
|
|
categories={@categories}
|
|
current_slug={
|
|
case @current_category do
|
|
:sale -> "sale"
|
|
nil -> nil
|
|
cat -> cat.slug
|
|
end
|
|
}
|
|
sort_options={@sort_options}
|
|
current_sort={@current_sort}
|
|
/>
|
|
|
|
<.product_grid theme_settings={@theme_settings}>
|
|
<%= for product <- @products do %>
|
|
<.product_card
|
|
product={product}
|
|
theme_settings={@theme_settings}
|
|
mode={@mode}
|
|
variant={:default}
|
|
show_category={@current_category in [nil, :sale]}
|
|
/>
|
|
<% end %>
|
|
</.product_grid>
|
|
|
|
<%= if @products == [] do %>
|
|
<div class="text-center py-16" style="color: var(--t-text-secondary);">
|
|
<p class="text-lg">No products found in this collection.</p>
|
|
<.link
|
|
navigate={~p"/collections/all"}
|
|
class="mt-4 inline-block underline"
|
|
style="color: var(--t-text-accent);"
|
|
>
|
|
View all products
|
|
</.link>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</main>
|
|
</.shop_layout>
|
|
"""
|
|
end
|
|
|
|
defp collection_filter_bar(assigns) do
|
|
~H"""
|
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
|
|
<nav aria-label="Collection filters" class="overflow-x-auto -mx-4 px-4 sm:mx-0 sm:px-0">
|
|
<ul class="flex gap-1.5 sm:flex-wrap sm:gap-2">
|
|
<li class="shrink-0">
|
|
<.link
|
|
navigate={collection_path("all", @current_sort)}
|
|
class={[
|
|
"px-3 py-1.5 sm:px-4 sm:py-2 rounded-full text-xs sm:text-sm whitespace-nowrap transition-colors",
|
|
if(@current_slug == nil, do: "font-medium", else: "hover:opacity-80")
|
|
]}
|
|
style={
|
|
if(@current_slug == nil,
|
|
do: "background-color: var(--t-accent); color: var(--t-text-on-accent);",
|
|
else: "background-color: var(--t-surface-raised); color: var(--t-text-primary);"
|
|
)
|
|
}
|
|
>
|
|
All
|
|
</.link>
|
|
</li>
|
|
<li class="shrink-0">
|
|
<.link
|
|
navigate={collection_path("sale", @current_sort)}
|
|
class={[
|
|
"px-3 py-1.5 sm:px-4 sm:py-2 rounded-full text-xs sm:text-sm whitespace-nowrap transition-colors",
|
|
if(@current_slug == "sale", do: "font-medium", else: "hover:opacity-80")
|
|
]}
|
|
style={
|
|
if(@current_slug == "sale",
|
|
do: "background-color: var(--t-accent); color: var(--t-text-on-accent);",
|
|
else: "background-color: var(--t-surface-raised); color: var(--t-text-primary);"
|
|
)
|
|
}
|
|
>
|
|
Sale
|
|
</.link>
|
|
</li>
|
|
<%= for category <- @categories do %>
|
|
<li class="shrink-0">
|
|
<.link
|
|
navigate={collection_path(category.slug, @current_sort)}
|
|
class={[
|
|
"px-3 py-1.5 sm:px-4 sm:py-2 rounded-full text-xs sm:text-sm whitespace-nowrap transition-colors",
|
|
if(@current_slug == category.slug, do: "font-medium", else: "hover:opacity-80")
|
|
]}
|
|
style={
|
|
if(@current_slug == category.slug,
|
|
do: "background-color: var(--t-accent); color: var(--t-text-on-accent);",
|
|
else: "background-color: var(--t-surface-raised); color: var(--t-text-primary);"
|
|
)
|
|
}
|
|
>
|
|
{category.name}
|
|
</.link>
|
|
</li>
|
|
<% end %>
|
|
</ul>
|
|
</nav>
|
|
|
|
<form phx-change="sort_changed">
|
|
<.shop_select
|
|
name="sort"
|
|
options={@sort_options}
|
|
selected={@current_sort}
|
|
class="px-3 py-1.5 sm:px-4 sm:py-2 text-xs sm:text-sm"
|
|
aria-label="Sort products"
|
|
/>
|
|
</form>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|