feat: add shop storefront with optimized theme CSS

- Create LoadTheme plug for loading theme settings
- Add shop layout with inline CSS injection (~400 bytes vs 17KB full)
- Create ShopLive.Home at / using shared ShopComponents
- Wire up CSS cache invalidation when theme settings change
- Replace Phoenix welcome page with themed shop home page

The shop layout injects only the active theme CSS variables inline,
achieving a 97% reduction compared to the full variants file used
by the theme editor.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-17 16:19:35 +00:00
parent 97981a9884
commit 88636db9d2
7 changed files with 172 additions and 5 deletions

View File

@ -83,6 +83,13 @@ defmodule SimpleshopTheme.Settings do
settings = Ecto.Changeset.apply_changes(changeset)
json = Jason.encode!(settings)
put_setting("theme_settings", json, "json")
# Invalidate and rewarm CSS cache
alias SimpleshopTheme.Theme.{CSSCache, CSSGenerator}
CSSCache.invalidate()
css = CSSGenerator.generate(settings)
CSSCache.put(css)
{:ok, settings}
else
{:error, changeset}

View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title><%= assigns[:page_title] || @theme_settings.site_name %></.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
<!-- Generated theme CSS (only active values, not all variants) -->
<style id="theme-css"><%= Phoenix.HTML.raw(@generated_css) %></style>
</head>
<body class="h-full">
<div
class="shop-root h-full"
data-mood={@theme_settings.mood}
data-typography={@theme_settings.typography}
data-shape={@theme_settings.shape}
data-density={@theme_settings.density}
data-grid={@theme_settings.grid_columns}
data-header={@theme_settings.header_layout}
data-sticky={to_string(@theme_settings.sticky_header)}
data-layout={@theme_settings.layout_width}
data-shadow={@theme_settings.card_shadow}
>
<%= @inner_content %>
</div>
</body>
</html>

View File

@ -0,0 +1,41 @@
defmodule SimpleshopThemeWeb.ShopLive.Home 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
# Load theme settings (cached CSS for performance)
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, "Home")
|> 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)
{:ok, socket}
end
end

View File

@ -0,0 +1,43 @@
<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);">
<.skip_link />
<%= if @theme_settings.announcement_bar do %>
<.announcement_bar theme_settings={@theme_settings} />
<% end %>
<.shop_header theme_settings={@theme_settings} logo_image={@logo_image} header_image={@header_image} active_page="home" mode={:shop} cart_count={0} />
<main id="main-content">
<.hero_section
title="Original designs, printed on demand"
description="From art prints to apparel unique products created by independent artists and delivered straight to your door."
cta_text="Shop the collection"
cta_page="collection"
mode={:shop}
/>
<.category_nav categories={@preview_data.categories} mode={:shop} />
<.featured_products_section
title="Featured products"
products={@preview_data.products}
theme_settings={@theme_settings}
mode={:shop}
/>
<.image_text_section
title="Made with passion, printed with care"
description="Every design starts with an idea. We work with quality print partners to bring those ideas to life on premium products from gallery-quality art prints to everyday essentials."
image_url="/mockups/mountain-sunrise-print-3.jpg"
link_text="Learn more about the studio →"
link_page="about"
mode={:shop}
/>
</main>
<.shop_footer theme_settings={@theme_settings} mode={:shop} />
<.cart_drawer cart_items={[]} subtotal="£0.00" mode={:shop} />
<.search_modal hint_text={~s(Try searching for "mountain", "forest", or "ocean")} />
</div>

View File

@ -0,0 +1,39 @@
defmodule SimpleshopThemeWeb.Plugs.LoadTheme do
@moduledoc """
Plug that loads theme settings and generated CSS for public shop pages.
This plug:
1. Checks the ETS cache for pre-generated CSS
2. Falls back to generating CSS from theme settings on cache miss
3. Assigns both `theme_settings` and `generated_css` to the connection
The generated CSS contains only the active theme values (not all variants),
making it much smaller than the full theme-layer2-attributes.css file used
by the theme editor for live preview switching.
"""
import Plug.Conn
alias SimpleshopTheme.Settings
alias SimpleshopTheme.Theme.{CSSGenerator, CSSCache}
def init(opts), do: opts
def call(conn, _opts) do
{theme_settings, generated_css} =
case CSSCache.get() do
{:ok, css} ->
{Settings.get_theme_settings(), css}
:miss ->
settings = Settings.get_theme_settings()
css = CSSGenerator.generate(settings)
CSSCache.put(css)
{settings, css}
end
conn
|> assign(:theme_settings, theme_settings)
|> assign(:generated_css, generated_css)
end
end

View File

@ -17,10 +17,17 @@ defmodule SimpleshopThemeWeb.Router do
plug :accepts, ["json"]
end
scope "/", SimpleshopThemeWeb do
pipe_through :browser
pipeline :shop do
plug SimpleshopThemeWeb.Plugs.LoadTheme
end
get "/", PageController, :home
# Public storefront (root level)
scope "/", SimpleshopThemeWeb do
pipe_through [:browser, :shop]
live_session :public_shop, layout: {SimpleshopThemeWeb.Layouts, :shop} do
live "/", ShopLive.Home, :index
end
end
# Image serving routes (public, no auth required)

View File

@ -1,8 +1,8 @@
defmodule SimpleshopThemeWeb.PageControllerTest do
use SimpleshopThemeWeb.ConnCase
test "GET /", %{conn: conn} do
test "GET / renders the shop home page", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
assert html_response(conn, 200) =~ "Original designs, printed on demand"
end
end