feat: add /collections/:slug routes with category filtering

- Add ShopLive.Collection LiveView for collection pages
- Add products_by_category/1 and category_by_slug/1 to PreviewData
- Support /collections/all for all products view
- Remove /products route and ShopLive.Products (replaced by collections)
- Update all links to use /collections/all instead of /products
- Update category nav to link to /collections/:slug
- Update PDP breadcrumb to link to specific collection

URL structure now follows Shopify convention:
- /collections/all - All products
- /collections/art-prints - Art Prints collection
- /collections/apparel - Apparel collection
- etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-19 23:26:41 +00:00
parent 848c4ea146
commit 9fb836ca0d
6 changed files with 208 additions and 71 deletions

View File

@ -114,6 +114,34 @@ defmodule SimpleshopTheme.Theme.PreviewData do
end end
end end
@doc """
Returns a category by its slug.
Returns nil if not found.
"""
def category_by_slug(slug) do
Enum.find(categories(), fn cat -> cat.slug == slug end)
end
@doc """
Returns products filtered by category slug.
If slug is nil or "all", returns all products.
"""
def products_by_category(nil), do: products()
def products_by_category("all"), do: products()
def products_by_category(slug) do
case category_by_slug(slug) do
nil ->
[]
category ->
products()
|> Enum.filter(fn product -> product.category == category.name end)
end
end
@doc """ @doc """
Checks if the shop has real products. Checks if the shop has real products.

View File

@ -10,7 +10,7 @@
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<.breadcrumb items={[ <.breadcrumb items={[
%{label: "Home", page: "home", href: "/"}, %{label: "Home", page: "home", href: "/"},
%{label: @product.category, page: "collection", href: "/products"}, %{label: @product.category, page: "collection", href: "/collections/#{@product.category |> String.downcase() |> String.replace(" ", "-")}"},
%{label: @product.name, current: true} %{label: @product.name, current: true}
]} mode={@mode} /> ]} mode={@mode} />

View File

@ -184,9 +184,9 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">New arrivals</a></li> <li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">New arrivals</a></li>
<li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">Best sellers</a></li> <li><a href="#" phx-click="change_preview_page" phx-value-page="collection" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary); cursor: pointer;">Best sellers</a></li>
<% else %> <% else %>
<li><a href="/products" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">All products</a></li> <li><a href="/collections/all" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">All products</a></li>
<li><a href="/products?sort=newest" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">New arrivals</a></li> <li><a href="/collections/all" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">New arrivals</a></li>
<li><a href="/products?sort=popular" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">Best sellers</a></li> <li><a href="/collections/all" class="transition-colors hover:opacity-80" style="color: var(--t-text-secondary);">Best sellers</a></li>
<% end %> <% end %>
</ul> </ul>
</div> </div>
@ -327,7 +327,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<a href="#" phx-click="change_preview_page" phx-value-page="contact" aria-current={if @active_page == "contact", do: "page"} style="color: var(--t-text-secondary); text-decoration: none; cursor: pointer;">Contact</a> <a href="#" phx-click="change_preview_page" phx-value-page="contact" aria-current={if @active_page == "contact", do: "page"} style="color: var(--t-text-secondary); text-decoration: none; cursor: pointer;">Contact</a>
<% else %> <% else %>
<a href="/" aria-current={if @active_page == "home", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Home</a> <a href="/" aria-current={if @active_page == "home", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Home</a>
<a href="/products" aria-current={if @active_page in ["collection", "pdp"], do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Shop</a> <a href="/collections/all" aria-current={if @active_page in ["collection", "pdp"], do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Shop</a>
<a href="/about" aria-current={if @active_page == "about", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">About</a> <a href="/about" aria-current={if @active_page == "about", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">About</a>
<a href="/contact" aria-current={if @active_page == "contact", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Contact</a> <a href="/contact" aria-current={if @active_page == "contact", do: "page"} style="color: var(--t-text-secondary); text-decoration: none;">Contact</a>
<% end %> <% end %>
@ -1078,7 +1078,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</span> </span>
</a> </a>
<% else %> <% else %>
<a href={"/products?category=#{category[:slug] || category.name}"} class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none;"> <a href={"/collections/#{category.slug}"} class="flex flex-col items-center gap-3 p-4 rounded-lg transition-colors hover:bg-black/5" style="text-decoration: none;">
<div <div
class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105" class="w-24 h-24 rounded-full bg-gray-200 bg-cover bg-center transition-transform hover:scale-105"
style={"background-image: url('#{category.image_url}');"} style={"background-image: url('#{category.image_url}');"}
@ -1106,7 +1106,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
* `mode` - Either `:live` (default) or `:preview`. * `mode` - Either `:live` (default) or `:preview`.
* `cta_text` - Optional. Text for the "view all" button. Defaults to "View all products". * `cta_text` - Optional. Text for the "view all" button. Defaults to "View all products".
* `cta_page` - Optional. Page to navigate to (preview mode). Defaults to "collection". * `cta_page` - Optional. Page to navigate to (preview mode). Defaults to "collection".
* `cta_href` - Optional. URL for live mode. Defaults to "/products". * `cta_href` - Optional. URL for live mode. Defaults to "/collections/all".
## Examples ## Examples
@ -1124,7 +1124,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :cta_text, :string, default: "View all products" attr :cta_text, :string, default: "View all products"
attr :cta_page, :string, default: "collection" attr :cta_page, :string, default: "collection"
attr :cta_href, :string, default: "/products" attr :cta_href, :string, default: "/collections/all"
def featured_products_section(assigns) do def featured_products_section(assigns) do
~H""" ~H"""
@ -1785,7 +1785,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
</button> </button>
<% else %> <% else %>
<a <a
href="/products" href="/collections/all"
class="block w-full px-6 py-3 font-semibold transition-all text-center" class="block w-full px-6 py-3 font-semibold transition-all text-center"
style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); text-decoration: none;" style="border: 2px solid var(--t-border-default); color: var(--t-text-primary); border-radius: var(--t-radius-button); text-decoration: none;"
> >
@ -1812,7 +1812,7 @@ defmodule SimpleshopThemeWeb.ShopComponents do
<.breadcrumb items={[ <.breadcrumb items={[
%{label: "Home", page: "home", href: "/"}, %{label: "Home", page: "home", href: "/"},
%{label: "Art Prints", page: "collection", href: "/products?category=art-prints"}, %{label: "Art Prints", page: "collection", href: "/collections/art-prints"},
%{label: "Mountain Sunrise", current: true} %{label: "Mountain Sunrise", current: true}
]} mode={:preview} /> ]} mode={:preview} />
""" """

View File

@ -0,0 +1,169 @@
defmodule SimpleshopThemeWeb.ShopLive.Collection do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
@impl true
def mount(_params, _session, socket) do
theme_settings = Settings.get_theme_settings()
generated_css =
case CSSCache.get() do
{:ok, css} -> css
:miss ->
css = CSSGenerator.generate(theme_settings)
CSSCache.put(css)
css
end
logo_image = Media.get_logo()
header_image = Media.get_header()
socket =
socket
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
|> assign(:categories, PreviewData.categories())
{:ok, socket}
end
@impl true
def handle_params(%{"slug" => "all"}, _uri, socket) do
{:noreply,
socket
|> assign(:page_title, "All Products")
|> assign(:collection_title, "All Products")
|> assign(:current_category, nil)
|> assign(:products, PreviewData.products())}
end
def handle_params(%{"slug" => slug}, _uri, socket) do
case PreviewData.category_by_slug(slug) do
nil ->
{:noreply,
socket
|> put_flash(:error, "Collection not found")
|> push_navigate(to: ~p"/collections/all")}
category ->
products = PreviewData.products_by_category(slug)
{:noreply,
socket
|> assign(:page_title, category.name)
|> assign(:collection_title, category.name)
|> assign(:current_category, category)
|> assign(:products, products)}
end
end
@impl true
def render(assigns) do
~H"""
<div class="shop-container min-h-screen" style="background-color: var(--t-surface-base); font-family: var(--t-font-body); color: var(--t-text-primary);">
<SimpleshopThemeWeb.ShopComponents.skip_link />
<%= if @theme_settings.announcement_bar do %>
<SimpleshopThemeWeb.ShopComponents.announcement_bar theme_settings={@theme_settings} />
<% end %>
<SimpleshopThemeWeb.ShopComponents.shop_header
theme_settings={@theme_settings}
logo_image={@logo_image}
header_image={@header_image}
active_page="collection"
mode={@mode}
cart_count={@cart_count}
/>
<main id="main-content">
<SimpleshopThemeWeb.ShopComponents.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={@current_category && @current_category.slug} />
<SimpleshopThemeWeb.ShopComponents.product_grid theme_settings={@theme_settings}>
<%= for product <- @products do %>
<SimpleshopThemeWeb.ShopComponents.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:default}
show_category={@current_category == nil}
/>
<% end %>
</SimpleshopThemeWeb.ShopComponents.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>
<SimpleshopThemeWeb.ShopComponents.shop_footer theme_settings={@theme_settings} mode={@mode} />
<SimpleshopThemeWeb.ShopComponents.cart_drawer cart_items={@cart_items} subtotal={@cart_subtotal} mode={@mode} />
<SimpleshopThemeWeb.ShopComponents.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
</div>
"""
end
defp collection_filter_bar(assigns) do
~H"""
<nav class="mb-8" aria-label="Collection filters">
<ul class="flex flex-wrap gap-2">
<li>
<.link
navigate={~p"/collections/all"}
class={[
"px-4 py-2 rounded-full text-sm 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>
<%= for category <- @categories do %>
<li>
<.link
navigate={~p"/collections/#{category.slug}"}
class={[
"px-4 py-2 rounded-full text-sm 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>
"""
end
end

View File

@ -1,60 +0,0 @@
defmodule SimpleshopThemeWeb.ShopLive.Products do
use SimpleshopThemeWeb, :live_view
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Media
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator, PreviewData}
@impl true
def mount(_params, _session, socket) do
theme_settings = Settings.get_theme_settings()
generated_css =
case CSSCache.get() do
{:ok, css} -> css
:miss ->
css = CSSGenerator.generate(theme_settings)
CSSCache.put(css)
css
end
logo_image = Media.get_logo()
header_image = Media.get_header()
preview_data = %{
products: PreviewData.products(),
categories: PreviewData.categories()
}
socket =
socket
|> assign(:page_title, "Products")
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
|> assign(:logo_image, logo_image)
|> assign(:header_image, header_image)
|> assign(:preview_data, preview_data)
|> assign(:mode, :shop)
|> assign(:cart_items, [])
|> assign(:cart_count, 0)
|> assign(:cart_subtotal, "£0.00")
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<SimpleshopThemeWeb.PageTemplates.collection
theme_settings={@theme_settings}
logo_image={@logo_image}
header_image={@header_image}
preview_data={@preview_data}
mode={@mode}
cart_items={@cart_items}
cart_count={@cart_count}
cart_subtotal={@cart_subtotal}
/>
"""
end
end

View File

@ -29,7 +29,7 @@ defmodule SimpleshopThemeWeb.Router do
live "/", ShopLive.Home, :index live "/", ShopLive.Home, :index
live "/about", ShopLive.About, :index live "/about", ShopLive.About, :index
live "/contact", ShopLive.Contact, :index live "/contact", ShopLive.Contact, :index
live "/products", ShopLive.Products, :index live "/collections/:slug", ShopLive.Collection, :show
live "/products/:id", ShopLive.ProductShow, :show live "/products/:id", ShopLive.ProductShow, :show
live "/cart", ShopLive.Cart, :index live "/cart", ShopLive.Cart, :index
end end