fix navigation links, footer categories, product card structure, and social icons

- add missing cta_href to hero section and error page CTAs
- replace hardcoded footer shop links with real product categories
- restructure product cards with stretched-link pattern so category
  badges link to their collection page
- unify social icons: footer and contact page share the same default
  links from a single source in content.ex
- add search implementation plan (docs/plans/search.md, deferred)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-11 08:17:19 +00:00
parent ac46c1504f
commit 209ae7aee7
15 changed files with 186 additions and 161 deletions

View File

@ -53,11 +53,10 @@ Issues found during hands-on testing of the deployed prod site on mobile and des
- [ ] Shipping costs: add Stripe shipping options or query Printify for dynamic rates - [ ] Shipping costs: add Stripe shipping options or query Printify for dynamic rates
### Navigation & links ### Navigation & links
- [ ] Search doesn't work (modal opens but no results/functionality) - [x] "Shop the collection" button/link does nothing
- [ ] "Shop the collection" button/link does nothing - [x] Footer "New arrivals" and "Best sellers" links don't go anywhere
- [ ] Footer "New arrivals" and "Best sellers" links don't go anywhere - [x] Should be able to tap a category badge on product cards to go to that category
- [ ] Should be able to tap a category badge on product cards to go to that category - [x] Footer social icons should match the "Find me on" icons from the contact page
- [ ] Footer social icons should match the "Find me on" icons from the contact page
### Collections / all products ### Collections / all products
- [ ] Categories on all-products page are too spaced out - [ ] Categories on all-products page are too spaced out
@ -71,6 +70,9 @@ Issues found during hands-on testing of the deployed prod site on mobile and des
### Errors ### Errors
- [ ] 404 page is broken - [ ] 404 page is broken
### Search (deferred — after usability fixes)
- [ ] Search doesn't work (modal opens but no results/functionality) — see [docs/plans/search.md](docs/plans/search.md)
## Roadmap ## Roadmap
### Tier 1 — MVP (can take real orders and fulfil them) ### Tier 1 — MVP (can take real orders and fulfil them)

View File

@ -229,6 +229,14 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Stretched link: makes a parent container fully clickable via ::after overlay */
.stretched-link::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
}
/* Nav link styling with active state indicator */ /* Nav link styling with active state indicator */
.shop-nav a, .shop-nav a,
.shop-nav span { .shop-nav span {

61
docs/plans/search.md Normal file
View File

@ -0,0 +1,61 @@
# Plan: Implement product search in search modal
Status: Pending (after usability fixes)
## Overview
The search modal UI shell exists but has zero functionality — no event bindings, no backend search, no results rendering. This plan adds live search across all products.
## Approach
Small catalog (print-on-demand, < 100 products) search `PreviewData.products()` in memory. No DB full-text search needed.
Product maps have: `.name`, `.category`, `.description`, `.slug`, `.price`, `.image_url`, `.image_id`
## Changes
### 1. CartHook — add search assigns + event handler
**File:** `lib/simpleshop_theme_web/cart_hook.ex`
- Init assigns in `on_mount`: `search_results: []`, `search_query: ""`
- Handle `"search"` event (from `phx-keyup`):
- Empty/blank query → assign `search_results: []`, `search_query: ""`
- Non-empty → filter `PreviewData.products()` by name/category/description (case-insensitive substring match), take 6, assign results
- Handle `"close_search"` event → clear query + results + hide modal via JS
### 2. shop_layout + search_modal — add search attrs and UI
**File:** `lib/simpleshop_theme_web/components/shop_components/layout.ex`
**shop_layout:**
- Add optional attrs: `search_results` (default `[]`), `search_query` (default `""`)
- Pass them to `<.search_modal>`
**search_modal:**
- Add `search_results` (list, default `[]`) and `search_query` (string, default `""`) attrs
- Add `name="query"`, `phx-keyup="search"`, `phx-debounce="200"`, `value={@search_query}` to the input
- On close button + backdrop click: also push `"close_search"` event
- Results section below input:
- Each result: link to `/products/{slug}` with product name, category, formatted price
- "No results found" when query non-empty but no matches
- Hint text only shown when no query
- Click on result: navigate to product, close modal (JS.hide + close_search event)
### 3. Page templates — thread search assigns
**All 8 files in** `lib/simpleshop_theme_web/components/page_templates/`
Add two lines to each `<.shop_layout>` call:
```
search_results={assigns[:search_results] || []}
search_query={assigns[:search_query] || ""}
```
Same pattern as `cart_drawer_open` and `cart_status`.
## Files to modify
1. `lib/simpleshop_theme_web/cart_hook.ex`
2. `lib/simpleshop_theme_web/components/shop_components/layout.ex` (shop_layout + search_modal)
3. All 8 page templates in `lib/simpleshop_theme_web/components/page_templates/`
## Verification
- Browser: open search modal on multiple pages, type queries, verify results appear and link correctly
- `mix test` — all existing tests pass

View File

@ -19,6 +19,7 @@ defmodule SimpleshopThemeWeb.CartHook do
import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3] import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1, push_event: 3]
alias SimpleshopTheme.Cart alias SimpleshopTheme.Cart
alias SimpleshopTheme.Theme.PreviewData
def on_mount(:mount_cart, _params, session, socket) do def on_mount(:mount_cart, _params, session, socket) do
cart_items = Cart.get_from_session(session) cart_items = Cart.get_from_session(session)
@ -28,6 +29,7 @@ defmodule SimpleshopThemeWeb.CartHook do
|> update_cart_assigns(cart_items) |> update_cart_assigns(cart_items)
|> assign(:cart_drawer_open, false) |> assign(:cart_drawer_open, false)
|> assign(:cart_status, nil) |> assign(:cart_status, nil)
|> assign(:categories, PreviewData.categories())
|> attach_hook(:cart_events, :handle_event, &handle_cart_event/3) |> attach_hook(:cart_events, :handle_event, &handle_cart_event/3)
|> attach_hook(:cart_info, :handle_info, &handle_cart_info/2) |> attach_hook(:cart_info, :handle_info, &handle_cart_info/2)

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="cart" active_page="cart"
> >
<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">

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="checkout" active_page="checkout"
> >
<main id="main-content" class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> <main id="main-content" class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16">

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="collection" active_page="collection"
> >
<main id="main-content"> <main id="main-content">

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="contact" active_page="contact"
> >
<main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16"> <main id="main-content" class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
@ -34,13 +35,7 @@
<.newsletter_card /> <.newsletter_card />
<.social_links_card links={[ <.social_links_card />
%{platform: :instagram, url: "https://instagram.com", label: "Instagram"},
%{platform: :bluesky, url: "https://bsky.app", label: "Bluesky"},
%{platform: :mastodon, url: "https://mastodon.social", label: "Mastodon"},
%{platform: :kofi, url: "https://ko-fi.com", label: "Ko-fi"},
%{platform: :github, url: "https://github.com", label: "GitHub"}
]} />
</div> </div>
</div> </div>
</main> </main>

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page={@active_page} active_page={@active_page}
> >
<main id="main-content" class="content-page" style="background-color: var(--t-surface-base);"> <main id="main-content" class="content-page" style="background-color: var(--t-surface-base);">

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="error" active_page="error"
error_page error_page
> >
@ -24,8 +25,10 @@
description={@error_description} description={@error_description}
cta_text="Go to Homepage" cta_text="Go to Homepage"
cta_page="home" cta_page="home"
cta_href="/"
secondary_cta_text="Browse Products" secondary_cta_text="Browse Products"
secondary_cta_page="collection" secondary_cta_page="collection"
secondary_cta_href="/collections/all"
mode={@mode} mode={@mode}
/> />

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="home" active_page="home"
> >
<main id="main-content"> <main id="main-content">
@ -16,6 +17,7 @@
description="Welcome to the SimpleShop demo store. This is where your hero text goes something short and punchy about what makes your shop worth a browse." description="Welcome to the SimpleShop demo store. This is where your hero text goes something short and punchy about what makes your shop worth a browse."
cta_text="Shop the collection" cta_text="Shop the collection"
cta_page="collection" cta_page="collection"
cta_href="/collections/all"
mode={@mode} mode={@mode}
/> />
@ -34,6 +36,7 @@
image_url="/mockups/mountain-sunrise-print-3-800.webp" image_url="/mockups/mountain-sunrise-print-3-800.webp"
link_text="Learn more about the studio →" link_text="Learn more about the studio →"
link_page="about" link_page="about"
link_href="/about"
mode={@mode} mode={@mode}
/> />
</main> </main>

View File

@ -8,6 +8,7 @@
cart_subtotal={@cart_subtotal} cart_subtotal={@cart_subtotal}
cart_drawer_open={assigns[:cart_drawer_open] || false} cart_drawer_open={assigns[:cart_drawer_open] || false}
cart_status={assigns[:cart_status]} cart_status={assigns[:cart_status]}
categories={assigns[:categories] || []}
active_page="pdp" active_page="pdp"
> >
<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">

View File

@ -5,6 +5,14 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
import SimpleshopThemeWeb.ShopComponents.Base import SimpleshopThemeWeb.ShopComponents.Base
@default_social_links [
%{platform: :instagram, url: "https://instagram.com", label: "Instagram"},
%{platform: :bluesky, url: "https://bsky.app", label: "Bluesky"},
%{platform: :mastodon, url: "https://mastodon.social", label: "Mastodon"},
%{platform: :kofi, url: "https://ko-fi.com", label: "Ko-fi"},
%{platform: :github, url: "https://github.com", label: "GitHub"}
]
@doc """ @doc """
Renders a content body container for long-form content pages (about, etc.). Renders a content body container for long-form content pages (about, etc.).
@ -338,11 +346,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
""" """
attr :title, :string, default: "Find me on" attr :title, :string, default: "Find me on"
attr :links, :list, attr :links, :list, default: @default_social_links
default: [
%{platform: :instagram, url: "#", label: "Instagram"},
%{platform: :pinterest, url: "#", label: "Pinterest"}
]
def social_links_card(assigns) do def social_links_card(assigns) do
~H""" ~H"""
@ -381,11 +385,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Content do
<.social_links /> <.social_links />
<.social_links links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} /> <.social_links links={[%{platform: :instagram, url: "https://instagram.com/example", label: "Instagram"}]} />
""" """
attr :links, :list, attr :links, :list, default: @default_social_links
default: [
%{platform: :instagram, url: "https://instagram.com", label: "Instagram"},
%{platform: :pinterest, url: "https://pinterest.com", label: "Pinterest"}
]
def social_links(assigns) do def social_links(assigns) do
~H""" ~H"""

View File

@ -65,6 +65,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
attr :cart_subtotal, :string, required: true attr :cart_subtotal, :string, required: true
attr :cart_drawer_open, :boolean, default: false attr :cart_drawer_open, :boolean, default: false
attr :cart_status, :string, default: nil attr :cart_status, :string, default: nil
attr :categories, :list, default: []
attr :active_page, :string, required: true attr :active_page, :string, required: true
attr :error_page, :boolean, default: false attr :error_page, :boolean, default: false
@ -95,7 +96,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
{render_slot(@inner_block)} {render_slot(@inner_block)}
<.shop_footer theme_settings={@theme_settings} mode={@mode} /> <.shop_footer theme_settings={@theme_settings} mode={@mode} categories={@categories} />
<.cart_drawer <.cart_drawer
cart_items={@cart_items} cart_items={@cart_items}
@ -405,6 +406,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
""" """
attr :theme_settings, :map, required: true attr :theme_settings, :map, required: true
attr :mode, :atom, default: :live attr :mode, :atom, default: :live
attr :categories, :list, default: []
def shop_footer(assigns) do def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year) assigns = assign(assigns, :current_year, Date.utc_today().year)
@ -437,28 +439,19 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
All products All products
</a> </a>
</li> </li>
<li> <%= for category <- @categories do %>
<a <li>
href="#" <a
phx-click="change_preview_page" href="#"
phx-value-page="collection" phx-click="change_preview_page"
class="transition-colors hover:opacity-80" phx-value-page="collection"
style="color: var(--t-text-secondary); cursor: pointer;" class="transition-colors hover:opacity-80"
> style="color: var(--t-text-secondary); cursor: pointer;"
New arrivals >
</a> {category.name}
</li> </a>
<li> </li>
<a <% end %>
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> <li>
<a <a
@ -469,24 +462,17 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
All products All products
</a> </a>
</li> </li>
<li> <%= for category <- @categories do %>
<a <li>
href="/collections/all" <a
class="transition-colors hover:opacity-80" href={"/collections/#{category.slug}"}
style="color: var(--t-text-secondary);" class="transition-colors hover:opacity-80"
> style="color: var(--t-text-secondary);"
New arrivals >
</a> {category.name}
</li> </a>
<li> </li>
<a <% end %>
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>
@ -594,48 +580,7 @@ defmodule SimpleshopThemeWeb.ShopComponents.Layout do
<p class="text-xs" style="color: var(--t-text-tertiary);"> <p class="text-xs" style="color: var(--t-text-tertiary);">
© {@current_year} {@theme_settings.site_name} © {@current_year} {@theme_settings.site_name}
</p> </p>
<div class="flex gap-2"> <.social_links />
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
class="social-link w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
aria-label="Instagram"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect>
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"></path>
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line>
</svg>
</a>
<a
href="https://pinterest.com"
target="_blank"
rel="noopener noreferrer"
class="social-link w-9 h-9 flex items-center justify-center transition-all"
style="color: var(--t-text-secondary); border-radius: var(--t-radius-button);"
aria-label="Pinterest"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 12c0-2.2 1.8-4 4-4s4 1.8 4 4-1.8 4-4 4"></path>
<line x1="12" y1="16" x2="9" y2="21"></line>
</svg>
</a>
</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -59,58 +59,22 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
end) end)
~H""" ~H"""
<%= if @clickable_resolved do %> <article
<%= if @mode == :preview do %> class={card_classes(@variant)}
<a style={card_style(@variant) <> if(@clickable_resolved, do: " position: relative;", else: "")}
href="#" >
phx-click="change_preview_page" <.product_card_inner
phx-value-page="pdp" product={@product}
class={card_classes(@variant)} theme_settings={@theme_settings}
style={card_style(@variant)} variant={@variant}
> priority={@priority}
<.product_card_inner show_category={@show_category_resolved}
product={@product} show_badges={@show_badges_resolved}
theme_settings={@theme_settings} show_delivery_text={@show_delivery_text_resolved}
variant={@variant} clickable={@clickable_resolved}
priority={@priority} mode={@mode}
show_category={@show_category_resolved} />
show_badges={@show_badges_resolved} </article>
show_delivery_text={@show_delivery_text_resolved}
/>
</a>
<% else %>
<a
href={"/products/#{@product[:slug] || @product[:id]}"}
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
priority={@priority}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</a>
<% end %>
<% else %>
<div
class={card_classes(@variant)}
style={card_style(@variant)}
>
<.product_card_inner
product={@product}
theme_settings={@theme_settings}
variant={@variant}
priority={@priority}
show_category={@show_category_resolved}
show_badges={@show_badges_resolved}
show_delivery_text={@show_delivery_text_resolved}
/>
</div>
<% end %>
""" """
end end
@ -121,6 +85,8 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
attr :show_category, :boolean, required: true attr :show_category, :boolean, required: true
attr :show_badges, :boolean, required: true attr :show_badges, :boolean, required: true
attr :show_delivery_text, :boolean, required: true attr :show_delivery_text, :boolean, required: true
attr :clickable, :boolean, default: true
attr :mode, :atom, default: :live
defp product_card_inner(assigns) do defp product_card_inner(assigns) do
assigns = assigns =
@ -171,12 +137,47 @@ defmodule SimpleshopThemeWeb.ShopComponents.Product do
</div> </div>
<div class={content_padding_class(@variant)}> <div class={content_padding_class(@variant)}>
<%= if @show_category && @product[:category] do %> <%= if @show_category && @product[:category] do %>
<p class="text-xs mb-1" style="color: var(--t-text-tertiary);"> <%= if @mode == :preview do %>
{@product.category} <p
</p> class="text-xs mb-1"
style="color: var(--t-text-tertiary); position: relative; z-index: 1;"
>
{@product.category}
</p>
<% else %>
<a
href={"/collections/#{Slug.slugify(@product.category)}"}
class="text-xs mb-1 block hover:underline"
style="color: var(--t-text-tertiary); text-decoration: none; position: relative; z-index: 1;"
>
{@product.category}
</a>
<% end %>
<% end %> <% end %>
<h3 class={title_classes(@variant)} style={title_style(@variant)}> <h3 class={title_classes(@variant)} style={title_style(@variant)}>
{@product.name} <%= if @clickable do %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="pdp"
class="stretched-link"
style="color: inherit; text-decoration: none;"
>
{@product.name}
</a>
<% else %>
<a
href={"/products/#{@product[:slug] || @product[:id]}"}
class="stretched-link"
style="color: inherit; text-decoration: none;"
>
{@product.name}
</a>
<% end %>
<% else %>
{@product.name}
<% end %>
</h3> </h3>
<%= if @theme_settings.show_prices do %> <%= if @theme_settings.show_prices do %>
<.product_price product={@product} variant={@variant} /> <.product_price product={@product} variant={@variant} />