Adds cheapest_price, compare_at_price, in_stock, on_sale columns to products table (recomputed from variants after each sync). Shop components now work with Product structs directly instead of plain maps from PreviewData. Renames .name to .title, adds Product display helpers (primary_image, hover_image, option_types) and ProductImage helpers (display_url, direct_url, source_width). Adds Products context query functions for storefront use (list_visible_products, get_visible_product, list_categories with DB-level sort/filter). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
229 lines
7.4 KiB
Elixir
229 lines
7.4 KiB
Elixir
defmodule SimpleshopThemeWeb.Shop.Collection do
|
|
use SimpleshopThemeWeb, :live_view
|
|
|
|
alias SimpleshopTheme.Theme.PreviewData
|
|
|
|
@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(:categories, PreviewData.categories())
|
|
|> 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) 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, sort_products(products, sort))}
|
|
|
|
:not_found ->
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:error, "Collection not found")
|
|
|> push_navigate(to: ~p"/collections/all")}
|
|
end
|
|
end
|
|
|
|
defp load_collection("all") do
|
|
{:ok, "All Products", nil, PreviewData.products()}
|
|
end
|
|
|
|
defp load_collection("sale") do
|
|
sale_products = Enum.filter(PreviewData.products(), & &1.on_sale)
|
|
{:ok, "Sale", :sale, sale_products}
|
|
end
|
|
|
|
defp load_collection(slug) do
|
|
case PreviewData.category_by_slug(slug) do
|
|
nil -> :not_found
|
|
category -> {:ok, category.name, category, PreviewData.products_by_category(slug)}
|
|
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 sort_products(products, "featured"), do: products
|
|
defp sort_products(products, "newest"), do: Enum.reverse(products)
|
|
defp sort_products(products, "price_asc"), do: Enum.sort_by(products, & &1.cheapest_price)
|
|
|
|
defp sort_products(products, "price_desc"),
|
|
do: Enum.sort_by(products, & &1.cheapest_price, :desc)
|
|
|
|
defp sort_products(products, "name_asc"), do: Enum.sort_by(products, & &1.title)
|
|
defp sort_products(products, "name_desc"), do: Enum.sort_by(products, & &1.title, :desc)
|
|
defp sort_products(products, _), do: products
|
|
|
|
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"
|
|
>
|
|
<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
|