rename project from SimpleshopTheme to Berrypod

All modules, configs, paths, and references updated.
836 tests pass, zero warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-18 21:23:15 +00:00
parent c65e777832
commit 9528700862
300 changed files with 23932 additions and 1349 deletions

View File

@@ -0,0 +1,524 @@
defmodule BerrypodWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
Styled with custom admin CSS (`assets/css/admin/components.css`).
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
"""
use Phoenix.Component
use Gettext, backend: BerrypodWeb.Gettext
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="admin-toast"
{@rest}
>
<div class={[
"admin-alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "admin-alert-info",
@kind == :error && "admin-alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :string
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "admin-btn-primary", nil => "admin-btn-primary admin-btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["admin-btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as hidden and radio,
are best written directly in your templates.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :string, default: nil, doc: "the input class to use over defaults"
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="admin-fieldset">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="admin-label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "admin-checkbox admin-checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="admin-fieldset">
<label>
<span :if={@label} class="admin-label">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "admin-select", @errors != [] && (@error_class || "admin-input-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="admin-fieldset">
<label>
<span :if={@label} class="admin-label">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "admin-textarea",
@errors != [] && (@error_class || "admin-input-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="admin-fieldset">
<label>
<span :if={@label} class="admin-label">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "admin-input",
@errors != [] && (@error_class || "admin-input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="admin-error mt-1.5 flex gap-2 items-center text-sm">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="admin-list">
<li :for={item <- @item} class="admin-list-row">
<div class="admin-list-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled into
`admin/icons.css` by `mix generate_admin_icons`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(BerrypodWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(BerrypodWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Renders a modal dialog.
Uses daisyUI's modal component with proper accessibility.
## Examples
<.modal id="confirm-modal">
Are you sure?
<:actions>
<button class="btn">Cancel</button>
<button class="btn btn-primary">Confirm</button>
</:actions>
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
slot :actions
def modal(assigns) do
~H"""
<dialog
id={@id}
class="admin-modal"
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
>
<div class="admin-modal-box">
<form method="dialog" class="admin-modal-close">
<button
class="admin-btn admin-btn-ghost admin-btn-icon-round admin-btn-sm"
phx-click={@on_cancel}
aria-label={gettext("close")}
>
<.icon name="hero-x-mark" class="size-5" />
</button>
</form>
{render_slot(@inner_block)}
<div :if={@actions != []} class="admin-modal-actions">
{render_slot(@actions)}
</div>
</div>
</dialog>
"""
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.exec("showModal()", to: "##{id}")
end
def hide_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.exec("close()", to: "##{id}")
|> JS.pop_focus()
end
end

View File

@@ -0,0 +1,136 @@
defmodule BerrypodWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use BerrypodWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<main class="px-4 py-12 sm:px-6 lg:px-8">
<div class="mx-auto max-w-lg flex flex-col gap-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc false
def admin_nav_active?(current_path, "/admin") do
if current_path == "/admin", do: "active", else: nil
end
def admin_nav_active?(current_path, link_path) do
if String.starts_with?(current_path, link_path), do: "active", else: nil
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in admin.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="theme-toggle-indicator absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end

View File

@@ -0,0 +1,108 @@
<div class="admin-layout h-full">
<input id="admin-drawer" type="checkbox" class="admin-layout-toggle" />
<%!-- main content area --%>
<div class="admin-layout-content">
<%!-- mobile header --%>
<header class="admin-topbar">
<label
for="admin-drawer"
class="admin-btn admin-btn-ghost admin-btn-icon"
aria-label="Open navigation"
>
<.icon name="hero-bars-3" class="size-5" />
</label>
<span class="admin-topbar-title">Berrypod</span>
<.link href={~p"/"} class="admin-btn admin-btn-ghost admin-btn-sm">
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> Shop
</.link>
</header>
<%!-- page content --%>
<main class="flex-1 p-4 sm:p-6 lg:p-8">
<div class="mx-auto max-w-5xl">
{@inner_content}
</div>
</main>
</div>
<%!-- sidebar --%>
<div class="admin-sidebar-wrapper">
<label for="admin-drawer" class="admin-sidebar-overlay" aria-label="Close navigation"></label>
<aside class="admin-sidebar">
<%!-- sidebar header --%>
<div class="p-4 border-b border-base-300">
<.link navigate={~p"/admin"} class="text-lg font-bold tracking-tight">
Berrypod
</.link>
<p class="text-xs text-base-content/60 mt-0.5 truncate">
{@current_scope.user.email}
</p>
</div>
<%!-- nav links --%>
<nav class="flex-1 p-2" aria-label="Admin navigation">
<ul class="admin-nav">
<li>
<.link
navigate={~p"/admin"}
class={admin_nav_active?(@current_path, "/admin")}
>
<.icon name="hero-home" class="size-5" /> Dashboard
</.link>
</li>
<li>
<.link
navigate={~p"/admin/orders"}
class={admin_nav_active?(@current_path, "/admin/orders")}
>
<.icon name="hero-shopping-bag" class="size-5" /> Orders
</.link>
</li>
<li>
<.link
navigate={~p"/admin/products"}
class={admin_nav_active?(@current_path, "/admin/products")}
>
<.icon name="hero-cube" class="size-5" /> Products
</.link>
</li>
<li>
<.link
href={~p"/admin/theme"}
class={admin_nav_active?(@current_path, "/admin/theme")}
>
<.icon name="hero-paint-brush" class="size-5" /> Theme
</.link>
</li>
<li>
<.link
navigate={~p"/admin/settings"}
class={admin_nav_active?(@current_path, "/admin/settings")}
>
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
</.link>
</li>
</ul>
</nav>
<%!-- sidebar footer --%>
<div class="p-2 border-t border-base-300">
<ul class="admin-nav">
<li>
<.link href={~p"/"}>
<.icon name="hero-arrow-top-right-on-square" class="size-5" /> View shop
</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">
<.icon name="hero-arrow-right-start-on-rectangle" class="size-5" /> Log out
</.link>
</li>
</ul>
</div>
</aside>
</div>
</div>
<.flash_group flash={@flash} />

View File

@@ -0,0 +1,40 @@
<!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 default="Admin" suffix=" · Berrypod">
{assigns[:page_title]}
</.live_title>
<!-- Pre-declare layer order so shop reset < Tailwind base regardless of load order -->
<style>
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
</style>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
<script defer phx-track-static src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body class="h-full">
{@inner_content}
</body>
</html>

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<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 default="Berrypod" suffix=" · Phoenix Framework">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/admin.css"} />
<script defer phx-track-static src={~p"/assets/js/app.js"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body>
{@inner_content}
</body>
</html>

View File

@@ -0,0 +1,2 @@
<.shop_flash_group flash={@flash} />
{@inner_content}

View File

@@ -0,0 +1,51 @@
<!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()} />
<meta
name="description"
content={
assigns[:page_description] || @theme_settings.site_description ||
"Welcome to #{@theme_settings.site_name}"
}
/>
<.live_title>{assigns[:page_title] || @theme_settings.site_name}</.live_title>
<!-- Preload critical fonts for the current typography preset -->
<%= for preload <- Berrypod.Theme.Fonts.preload_links(
@theme_settings.typography,
&BerrypodWeb.Endpoint.static_path/1
) do %>
<link rel="preload" href={preload.href} as="font" type="font/woff2" crossorigin />
<% end %>
<!-- Pre-declare layer order so reset < components regardless of load order -->
<style>
@layer properties, reset, primitives, tokens, theme, base, components, layout, utilities, overrides;
</style>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/shop.css"} />
<script defer phx-track-static src={~p"/assets/js/app.js"}>
</script>
<!-- Generated theme CSS with @font-face declarations -->
<style id="theme-css">
<%= Phoenix.HTML.raw(@generated_css) %>
</style>
</head>
<body class="h-full">
<div
class="themed 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}
data-button-style={@theme_settings.button_style}
>
{@inner_content}
</div>
</body>
</html>

View File

@@ -0,0 +1,21 @@
defmodule BerrypodWeb.PageTemplates do
@moduledoc """
Shared page templates used by both the public shop and theme preview.
These templates accept a `mode` parameter to control navigation behavior:
- `:shop` - Links navigate normally (real shop pages)
- `:preview` - Links send events to parent LiveView (theme editor)
All templates expect these common assigns:
- `theme_settings` - Current theme configuration
- `logo_image` - Logo image struct or nil
- `header_image` - Header image struct or nil
- `mode` - `:shop` or `:preview`
- `cart_items` - List of cart items (can be empty)
- `cart_count` - Number of items in cart
"""
use Phoenix.Component
use BerrypodWeb.ShopComponents
embed_templates "page_templates/*"
end

View File

@@ -0,0 +1,37 @@
<.shop_layout {layout_assigns(assigns)} active_page="cart">
<main id="main-content" class="page-container">
<.page_title text="Your basket" />
<%= if @cart_items == [] do %>
<.cart_empty_state mode={@mode} />
<% else %>
<div class="cart-grid">
<div>
<ul
role="list"
aria-label="Cart items"
class="cart-page-list"
>
<%= for item <- @cart_items do %>
<li>
<.shop_card class="cart-page-card">
<.cart_item_row item={item} size={:default} show_quantity_controls mode={@mode} />
</.shop_card>
</li>
<% end %>
</ul>
</div>
<div>
<.order_summary
subtotal={@cart_page_subtotal}
shipping_estimate={assigns[:shipping_estimate]}
country_code={assigns[:country_code] || "GB"}
available_countries={assigns[:available_countries] || []}
mode={@mode}
/>
</div>
</div>
<% end %>
</main>
</.shop_layout>

View File

@@ -0,0 +1,134 @@
<.shop_layout {layout_assigns(assigns)} active_page="checkout">
<main id="main-content" class="page-container checkout-main">
<%= if @order && @order.payment_status == "paid" do %>
<div class="checkout-header">
<div class="checkout-icon">
<svg
width="32"
height="32"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<h1 class="checkout-heading">
Thank you for your order
</h1>
<p class="checkout-meta">
Order <strong>{@order.order_number}</strong>
</p>
<%= if @order.customer_email do %>
<p class="checkout-meta">
A confirmation will be sent to <strong>{@order.customer_email}</strong>
</p>
<% end %>
</div>
<.shop_card class="checkout-card">
<h2 class="checkout-heading">
Order details
</h2>
<ul class="checkout-items">
<%= for item <- @order.items do %>
<li class="checkout-item">
<div>
<p class="checkout-item-name">
{item.product_name}
</p>
<%= if item.variant_title do %>
<p class="checkout-item-detail">
{item.variant_title}
</p>
<% end %>
<p class="checkout-item-detail">
Qty: {item.quantity}
</p>
</div>
<span class="checkout-item-price">
{Berrypod.Cart.format_price(item.unit_price * item.quantity)}
</span>
</li>
<% end %>
</ul>
<div class="checkout-total-border">
<div class="checkout-total">
<span class="checkout-total-label">Total</span>
<span class="checkout-total-amount">
{Berrypod.Cart.format_price(@order.total)}
</span>
</div>
</div>
</.shop_card>
<%= if @order.shipping_address != %{} do %>
<.shop_card class="checkout-card">
<h2 class="checkout-heading">
Shipping to
</h2>
<div class="checkout-shipping-address">
<p>{@order.shipping_address["name"]}</p>
<p>{@order.shipping_address["line1"]}</p>
<%= if @order.shipping_address["line2"] do %>
<p>{@order.shipping_address["line2"]}</p>
<% end %>
<p>
{@order.shipping_address["city"]}, {@order.shipping_address["postal_code"]}
</p>
<p>{@order.shipping_address["country"]}</p>
</div>
</.shop_card>
<% end %>
<div class="checkout-actions">
<.shop_link_button href="/collections/all" class="checkout-cta">
Continue shopping
</.shop_link_button>
</div>
<% else %>
<%!-- Payment pending or order not found --%>
<div class="checkout-header">
<div class="checkout-pending-icon">
<span class="checkout-pending-spinner">
<svg
width="32"
height="32"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</span>
</div>
<h1 class="checkout-heading">
Processing your payment
</h1>
<p class="checkout-pending-text">
Please wait while we confirm your payment. This usually takes a few seconds.
</p>
<p class="checkout-pending-hint">
If this page doesn't update, please <.link
navigate="/contact"
class="checkout-contact-link"
>contact us</.link>.
</p>
</div>
<% end %>
</main>
</.shop_layout>

View File

@@ -0,0 +1,21 @@
<.shop_layout {layout_assigns(assigns)} active_page="collection">
<main id="main-content">
<.collection_header title="All Products" product_count={length(assigns[:products] || [])} />
<div class="page-container">
<.filter_bar categories={assigns[:categories] || []} />
<.product_grid theme_settings={@theme_settings}>
<%= for product <- assigns[:products] || [] do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:default}
show_category={true}
/>
<% end %>
</.product_grid>
</div>
</main>
</.shop_layout>

View File

@@ -0,0 +1,30 @@
<.shop_layout {layout_assigns(assigns)} active_page="contact">
<main id="main-content" class="page-container contact-main">
<.hero_section
variant={:page}
title="Get in touch"
description="Sample contact page for the demo store. Add your own message here something friendly about how customers can reach you."
/>
<div class="contact-grid">
<.contact_form email="hello@example.com" />
<div class="contact-sidebar">
<.order_tracking_card />
<.info_card
title="Handy to know"
items={[
%{label: "Printing", value: "Example: 2-5 business days"},
%{label: "Delivery", value: "Example: 3-7 business days after printing"},
%{label: "Issues", value: "Example: Reprints for any defects"}
]}
/>
<.newsletter_card />
<.social_links_card />
</div>
</div>
</main>
</.shop_layout>

View File

@@ -0,0 +1,33 @@
<.shop_layout {layout_assigns(assigns)}>
<main id="main-content" class="content-page">
<%= if assigns[:hero_background] do %>
<.hero_section
title={@hero_title}
description={@hero_description}
background={@hero_background}
/>
<% else %>
<.hero_section
variant={:page}
title={@hero_title}
description={@hero_description}
/>
<% end %>
<div class="content-body">
<%= if assigns[:image_src] do %>
<div class="content-image">
<.responsive_image
src={@image_src}
source_width={1200}
alt={@image_alt}
sizes="(max-width: 800px) 100vw, 800px"
class="content-hero-image"
/>
</div>
<% end %>
<.rich_text blocks={@content_blocks} />
</div>
</main>
</.shop_layout>

View File

@@ -0,0 +1,33 @@
<.shop_layout {layout_assigns(assigns)} active_page="error" error_page>
<main
id="main-content"
class="error-main"
>
<div class="page-container error-container">
<.hero_section
variant={:error}
pre_title={@error_code}
title={@error_title}
description={@error_description}
cta_text="Go to Homepage"
cta_page="home"
cta_href="/"
secondary_cta_text="Browse Products"
secondary_cta_page="collection"
secondary_cta_href="/collections/all"
mode={@mode}
/>
<.product_grid columns={:fixed_4}>
<%= for product <- Enum.take(assigns[:products] || [], 4) do %>
<.product_card
product={product}
theme_settings={@theme_settings}
mode={@mode}
variant={:minimal}
/>
<% end %>
</.product_grid>
</div>
</main>
</.shop_layout>

View File

@@ -0,0 +1,31 @@
<.shop_layout {layout_assigns(assigns)} active_page="home">
<main id="main-content">
<.hero_section
title="Original designs, printed on demand"
description="Welcome to the Berrypod 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_page="collection"
cta_href="/collections/all"
mode={@mode}
/>
<.category_nav categories={assigns[:categories] || []} mode={@mode} />
<.featured_products_section
title="Featured products"
products={assigns[:products] || []}
theme_settings={@theme_settings}
mode={@mode}
/>
<.image_text_section
title="Made with passion, printed with care"
description="This is an example content section. Use it to share your story, highlight what makes your products special, or link to your about page."
image_url="/mockups/mountain-sunrise-print-3-800.webp"
link_text="Learn more about the studio →"
link_page="about"
link_href="/about"
mode={@mode}
/>
</main>
</.shop_layout>

View File

@@ -0,0 +1,67 @@
<.shop_layout {layout_assigns(assigns)} active_page="pdp">
<main id="main-content" class="page-container">
<.breadcrumb
items={
if @product.category do
[
%{
label: @product.category,
page: "collection",
href:
"/collections/#{@product.category |> String.downcase() |> String.replace(" ", "-")}"
}
]
else
[]
end ++
[%{label: @product.title, current: true}]
}
mode={@mode}
/>
<div class="pdp-grid">
<.product_gallery images={@gallery_images} product_name={@product.title} />
<div>
<.product_info product={@product} display_price={@display_price} />
<%!-- Dynamic variant selectors --%>
<%= for option_type <- @option_types do %>
<.variant_selector
option_type={option_type}
selected={@selected_options[option_type.name]}
available={@available_options[option_type.name] || []}
mode={@mode}
/>
<% end %>
<%!-- Fallback for products with no variant options --%>
<div
:if={@option_types == []}
class="pdp-variant-fallback"
>
One size
</div>
<.quantity_selector quantity={@quantity} in_stock={@product.in_stock} />
<.add_to_cart_button mode={@mode} />
<.trust_badges :if={@theme_settings.pdp_trust_badges} />
<.product_details product={@product} />
</div>
</div>
<.reviews_section
:if={@theme_settings.pdp_reviews}
reviews={Berrypod.Theme.PreviewData.reviews()}
average_rating={5}
total_count={24}
/>
<.related_products_section
:if={@theme_settings.pdp_related_products}
products={@related_products}
theme_settings={@theme_settings}
mode={@mode}
/>
</main>
</.shop_layout>

View File

@@ -0,0 +1,23 @@
defmodule BerrypodWeb.ShopComponents do
@moduledoc """
Facade module for shop/storefront UI components.
`use BerrypodWeb.ShopComponents` imports all sub-modules:
- `Base` — themed inputs, buttons, cards
- `Layout` — header, footer, mobile nav, shop_layout wrapper
- `Cart` — cart drawer, cart items, order summary
- `Product` — product cards, gallery, variant selector, hero sections
- `Content` — rich text, responsive images, contact form, reviews
"""
defmacro __using__(_opts \\ []) do
quote do
import BerrypodWeb.ShopComponents.Base
import BerrypodWeb.ShopComponents.Cart
import BerrypodWeb.ShopComponents.Content
import BerrypodWeb.ShopComponents.Layout
import BerrypodWeb.ShopComponents.Product
end
end
end

View File

@@ -0,0 +1,243 @@
defmodule BerrypodWeb.ShopComponents.Base do
use Phoenix.Component
@doc """
Renders a themed text input.
This component applies the `.themed-input` CSS class which inherits
colors, borders, and radii from the current theme's CSS variables.
## Attributes
* `type` - Optional. Input type. Defaults to "text".
* `class` - Optional. Additional CSS classes.
* All other attributes are passed through to the input element.
## Examples
<.shop_input type="email" placeholder="your@email.com" />
<.shop_input type="text" name="name" class="flex-1" />
"""
attr :type, :string, default: "text"
attr :class, :string, default: nil
attr :rest, :global, include: ~w(name value placeholder required disabled autocomplete readonly)
def shop_input(assigns) do
~H"""
<input type={@type} class={["themed-input", @class]} {@rest} />
"""
end
@doc """
Renders a themed textarea.
## Attributes
* `class` - Optional. Additional CSS classes.
* All other attributes are passed through to the textarea element.
## Examples
<.shop_textarea placeholder="Your message..." rows="5" />
"""
attr :class, :string, default: nil
attr :rest, :global, include: ~w(name rows placeholder required disabled readonly)
def shop_textarea(assigns) do
~H"""
<textarea class={["themed-input", @class]} {@rest}></textarea>
"""
end
@doc """
Renders a themed select dropdown.
## Attributes
* `class` - Optional. Additional CSS classes.
* `options` - Required. List of options (strings or {value, label} tuples).
* `selected` - Optional. Currently selected value.
* All other attributes are passed through to the select element.
## Examples
<.shop_select options={["Option 1", "Option 2"]} />
<.shop_select options={[{"value", "Label"}]} selected="value" />
"""
attr :class, :string, default: nil
attr :options, :list, required: true
attr :selected, :any, default: nil
attr :rest, :global, include: ~w(name required disabled aria-label)
def shop_select(assigns) do
~H"""
<select class={["themed-select", @class]} {@rest}>
<%= for option <- @options do %>
<%= case option do %>
<% {value, label} -> %>
<option value={value} selected={@selected == value}>{label}</option>
<% label -> %>
<option selected={@selected == label}>{label}</option>
<% end %>
<% end %>
</select>
"""
end
@doc """
Renders a themed primary button (accent color background).
## Attributes
* `class` - Optional. Additional CSS classes.
* `type` - Optional. Button type. Defaults to "button".
* All other attributes are passed through to the button element.
## Slots
* `inner_block` - Required. Button content.
## Examples
<.shop_button>Send Message</.shop_button>
<.shop_button type="submit" class="w-full">Subscribe</.shop_button>
"""
attr :class, :string, default: nil
attr :type, :string, default: "button"
attr :rest, :global, include: ~w(disabled name value phx-click phx-value-page)
slot :inner_block, required: true
def shop_button(assigns) do
~H"""
<button type={@type} class={["themed-button", @class]} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
@doc """
Renders a themed outline/secondary button.
## Attributes
* `class` - Optional. Additional CSS classes.
* `type` - Optional. Button type. Defaults to "button".
* All other attributes are passed through to the button element.
## Slots
* `inner_block` - Required. Button content.
## Examples
<.shop_button_outline>Continue Shopping</.shop_button_outline>
"""
attr :class, :string, default: nil
attr :type, :string, default: "button"
attr :rest, :global, include: ~w(disabled name value phx-click phx-value-page)
slot :inner_block, required: true
def shop_button_outline(assigns) do
~H"""
<button type={@type} class={["themed-button-outline", @class]} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
@doc """
Renders a themed link styled as a primary button.
## Attributes
* `href` - Required. Link destination.
* `class` - Optional. Additional CSS classes.
## Slots
* `inner_block` - Required. Link content.
## Examples
<.shop_link_button href="/checkout">Checkout</.shop_link_button>
"""
attr :href, :string, required: true
attr :class, :string, default: nil
slot :inner_block, required: true
def shop_link_button(assigns) do
~H"""
<.link
navigate={@href}
class={["themed-button", @class]}
>
{render_slot(@inner_block)}
</.link>
"""
end
@doc """
Renders a themed link styled as an outline button.
## Attributes
* `href` - Required. Link destination.
* `class` - Optional. Additional CSS classes.
## Slots
* `inner_block` - Required. Link content.
## Examples
<.shop_link_outline href="/collections/all">Continue Shopping</.shop_link_outline>
"""
attr :href, :string, required: true
attr :class, :string, default: nil
slot :inner_block, required: true
def shop_link_outline(assigns) do
~H"""
<.link
navigate={@href}
class={["themed-button-outline", @class]}
>
{render_slot(@inner_block)}
</.link>
"""
end
@doc """
Renders a themed card container.
## Attributes
* `class` - Optional. Additional CSS classes.
## Slots
* `inner_block` - Required. Card content.
## Examples
<.shop_card class="p-6">
<h3>Card Title</h3>
<p>Card content...</p>
</.shop_card>
"""
attr :class, :string, default: nil
slot :inner_block, required: true
def shop_card(assigns) do
~H"""
<div class={["themed-card", @class]}>
{render_slot(@inner_block)}
</div>
"""
end
end

View File

@@ -0,0 +1,539 @@
defmodule BerrypodWeb.ShopComponents.Cart do
@moduledoc false
use Phoenix.Component
import BerrypodWeb.ShopComponents.Base
alias Berrypod.Products.{Product, ProductImage}
defp close_cart_drawer_js do
Phoenix.LiveView.JS.push("close_cart_drawer")
end
@doc """
Renders the cart drawer (floating sidebar).
The drawer slides in from the right when opened. It displays cart items
and checkout options. Follows WAI-ARIA dialog pattern for accessibility.
## Attributes
* `cart_items` - List of cart items to display. Each item should have
`image`, `name`, `variant`, `price`, and `variant_id` keys. Default: []
* `subtotal` - The subtotal to display. Default: nil (shows "£0.00")
* `cart_count` - Number of items for screen reader description. Default: 0
* `mode` - Either `:live` (default) for real stores or `:preview` for theme editor.
In preview mode, "View basket" navigates via LiveView JS commands.
## Examples
<.cart_drawer cart_items={@cart.items} subtotal={@cart.subtotal} />
<.cart_drawer cart_items={demo_items} subtotal="£72.00" mode={:preview} />
"""
attr :cart_items, :list, default: []
attr :subtotal, :string, default: nil
attr :total, :string, default: nil
attr :cart_count, :integer, default: 0
attr :cart_status, :string, default: nil
attr :mode, :atom, default: :live
attr :open, :boolean, default: false
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
def cart_drawer(assigns) do
assigns =
assign_new(assigns, :display_total, fn ->
assigns.total || assigns.subtotal || "£0.00"
end)
~H"""
<%!-- Screen reader announcements for cart changes --%>
<div aria-live="polite" aria-atomic="true" class="sr-only">
{@cart_status}
</div>
<!-- Cart Drawer Overlay -->
<div
id="cart-drawer-overlay"
class={["cart-drawer-overlay", @open && "open"]}
aria-hidden={to_string(!@open)}
phx-click={close_cart_drawer_js()}
>
</div>
<!-- Cart Drawer -->
<div
id="cart-drawer"
role="dialog"
aria-modal="true"
aria-labelledby="cart-drawer-title"
aria-describedby="cart-drawer-description"
aria-hidden={to_string(!@open)}
phx-hook="CartDrawer"
class={["cart-drawer", @open && "open"]}
>
<p id="cart-drawer-description" class="sr-only">
Shopping basket with {@cart_count} {if @cart_count == 1, do: "item", else: "items"}. Press Escape to close.
</p>
<div class="cart-drawer-header">
<h2
id="cart-drawer-title"
class="cart-drawer-title"
>
Your basket
</h2>
<button
type="button"
class="cart-drawer-close"
phx-click={close_cart_drawer_js()}
aria-label="Close cart"
>
<svg
class="cart-drawer-close-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="cart-drawer-items">
<%= if @cart_items == [] do %>
<.cart_empty_state mode={@mode} />
<% else %>
<ul role="list" aria-label="Cart items">
<%= for item <- @cart_items do %>
<li>
<.cart_item_row item={item} size={:compact} show_quantity_controls mode={@mode} />
</li>
<% end %>
</ul>
<% end %>
</div>
<div class="cart-drawer-footer">
<.delivery_line
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
mode={@mode}
/>
<div class="cart-drawer-total">
<span>{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}</span>
<span>{@display_total}</span>
</div>
<%= if @mode == :preview do %>
<button
type="button"
class="cart-drawer-checkout"
>
Checkout
</button>
<% else %>
<form action="/checkout" method="post">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<button
type="submit"
class="cart-drawer-checkout"
>
Checkout
</button>
</form>
<% end %>
</div>
</div>
"""
end
@doc """
Shared cart item row component used by both drawer and cart page.
## Attributes
* `item` - Required. Cart item with `name`, `variant`, `price`, `quantity`, `image`, `variant_id`, `product_id`.
* `size` - Either `:compact` (drawer) or `:default` (cart page). Default: :default
* `show_quantity_controls` - Show +/- buttons. Default: false
* `mode` - Either `:live` or `:preview`. Default: :live
"""
attr :item, :map, required: true
attr :size, :atom, default: :default
attr :show_quantity_controls, :boolean, default: false
attr :mode, :atom, default: :live
def cart_item_row(assigns) do
~H"""
<div
class="cart-item-row"
data-size={if @size == :compact, do: "compact"}
>
<%= if @mode != :preview do %>
<.link
navigate={"/products/#{@item.product_id}"}
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
data-size={if @size == :compact, do: "compact"}
style={if @item.image, do: "background-image: url('#{@item.image}');"}
aria-label={"View #{@item.name}"}
>
</.link>
<% else %>
<div
class={["cart-item-image", !@item.image && "cart-item-image--empty"]}
data-size={if @size == :compact, do: "compact"}
style={if @item.image, do: "background-image: url('#{@item.image}');"}
>
</div>
<% end %>
<div class="cart-item-details">
<h3 class="cart-item-name" data-size={if @size == :compact, do: "compact"}>
<%= if @mode != :preview do %>
<.link
navigate={"/products/#{@item.product_id}"}
class="cart-item-name-link"
>
{@item.name}
</.link>
<% else %>
<span>{@item.name}</span>
<% end %>
</h3>
<%= if @item.variant do %>
<p class="cart-item-variant-text">
{@item.variant}
</p>
<% end %>
<div class="cart-item-actions">
<%= if @show_quantity_controls do %>
<div class="cart-qty-group">
<button
type="button"
phx-click="decrement"
phx-value-id={@item.variant_id}
class="cart-qty-btn"
aria-label={"Decrease quantity of #{@item.name}"}
>
</button>
<span class="cart-qty-display">
{@item.quantity}
</span>
<button
type="button"
phx-click="increment"
phx-value-id={@item.variant_id}
class="cart-qty-btn"
aria-label={"Increase quantity of #{@item.name}"}
>
+
</button>
</div>
<% else %>
<span class="cart-qty-text">
Qty: {@item.quantity}
</span>
<% end %>
<.cart_remove_button variant_id={@item.variant_id} item_name={@item.name} />
</div>
</div>
<div class="cart-item-price-col">
<p class="cart-item-price" data-size={if @size == :compact, do: "compact"}>
{Berrypod.Cart.format_price(@item.price * @item.quantity)}
</p>
</div>
</div>
"""
end
@doc """
Cart empty state component.
"""
attr :mode, :atom, default: :live
def cart_empty_state(assigns) do
~H"""
<div class="cart-empty">
<svg
class="cart-empty-icon"
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
<line x1="3" y1="6" x2="21" y2="6"></line>
<path d="M16 10a4 4 0 01-8 0"></path>
</svg>
<p>Your basket is empty</p>
<%= if @mode == :preview do %>
<button
type="button"
phx-click="change_preview_page"
phx-value-page="collection"
class="cart-continue-link"
>
Continue shopping
</button>
<% else %>
<.link
navigate="/collections/all"
class="cart-continue-link"
>
Continue shopping
</.link>
<% end %>
</div>
"""
end
@doc """
Remove button for cart items.
"""
attr :variant_id, :string, required: true
attr :item_name, :string, default: "item"
def cart_remove_button(assigns) do
~H"""
<button
type="button"
phx-click="remove_item"
phx-value-id={@variant_id}
class="cart-remove-btn"
aria-label={"Remove #{@item_name} from cart"}
>
Remove
</button>
"""
end
@doc """
Renders a cart item row.
## Attributes
* `item` - Required. Map with `product` (containing `image_url`, `name`, `price`), `variant`, and `quantity`.
* `currency` - Optional. Currency symbol. Defaults to "£".
## Examples
<.cart_item item={item} />
"""
attr :item, :map, required: true
def cart_item(assigns) do
~H"""
<.shop_card class="cart-page-item">
<div class="cart-page-image">
<img
src={cart_item_image(@item.product)}
alt={@item.product.title}
width="96"
height="96"
loading="lazy"
/>
</div>
<div class="cart-page-item-info">
<h3 class="cart-page-item-name">
{@item.product.title}
</h3>
<p class="cart-page-item-variant">
{@item.variant}
</p>
<div class="cart-page-item-actions">
<div class="cart-qty-group">
<button class="cart-qty-btn"></button>
<span class="cart-qty-display">
{@item.quantity}
</span>
<button class="cart-qty-btn">+</button>
</div>
<button class="cart-page-item-remove">
Remove
</button>
</div>
</div>
<div class="cart-page-item-price-col">
<p class="cart-page-item-price">
{Berrypod.Cart.format_price(@item.product.cheapest_price * @item.quantity)}
</p>
</div>
</.shop_card>
"""
end
defp cart_item_image(product) do
ProductImage.url(Product.primary_image(product), 400)
end
# Shared delivery line used by both cart_drawer and order_summary.
# Shows a country <select> when rates are available, falls back to plain text.
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
defp delivery_line(assigns) do
~H"""
<div class="delivery-line">
<span class="delivery-line-label">
Delivery to
<%= if @available_countries != [] and @mode != :preview do %>
<form phx-change="change_country">
<select
name="country"
class="delivery-select"
aria-label="Delivery country"
>
<%= for {code, name} <- @available_countries do %>
<option value={code} selected={code == @country_code}>{name}</option>
<% end %>
</select>
</form>
<% else %>
<span>{Berrypod.Shipping.country_name(@country_code)}</span>
<% end %>
</span>
<%= if @shipping_estimate do %>
<span>{Berrypod.Cart.format_price(@shipping_estimate)}</span>
<% else %>
<span>Calculated at checkout</span>
<% end %>
</div>
"""
end
@doc """
Renders the order summary card.
## Attributes
* `subtotal` - Required. Subtotal amount (in pence/cents).
* `shipping_estimate` - Optional. Shipping estimate in pence.
* `country_code` - Optional. Current country code. Default "GB".
* `available_countries` - Optional. List of `{code, name}` tuples.
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.order_summary subtotal={3600} />
"""
attr :subtotal, :integer, required: true
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
attr :mode, :atom, default: :live
def order_summary(assigns) do
assigns =
assign(assigns, :estimated_total, assigns.subtotal + (assigns.shipping_estimate || 0))
~H"""
<.shop_card class="order-summary-card">
<h2 class="order-summary-heading">
Order summary
</h2>
<div class="order-summary-lines">
<div class="order-summary-line">
<span>Subtotal</span>
<span>
{Berrypod.Cart.format_price(@subtotal)}
</span>
</div>
<.delivery_line
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
mode={@mode}
/>
<div class="order-summary-divider">
<div class="order-summary-total">
<span>
{if @shipping_estimate, do: "Estimated total", else: "Subtotal"}
</span>
<span>
{Berrypod.Cart.format_price(@estimated_total)}
</span>
</div>
</div>
</div>
<%= if @mode == :preview do %>
<.shop_button class="order-summary-checkout">
Checkout
</.shop_button>
<.shop_button_outline
phx-click="change_preview_page"
phx-value-page="collection"
class="order-summary-continue"
>
Continue shopping
</.shop_button_outline>
<% else %>
<form action="/checkout" method="post" class="order-summary-checkout-form">
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_button type="submit" class="order-summary-checkout">
Checkout
</.shop_button>
</form>
<.shop_link_outline
href="/collections/all"
class="order-summary-continue"
>
Continue shopping
</.shop_link_outline>
<% end %>
</.shop_card>
"""
end
@doc """
Renders a cart items list with order summary layout.
## Attributes
* `items` - Required. List of cart items.
* `subtotal` - Required. Subtotal in pence/cents.
* `currency` - Optional. Currency symbol. Defaults to "£".
* `mode` - Either `:live` (default) or `:preview`.
## Examples
<.cart_layout items={@cart_items} subtotal={3600} mode={:preview} />
"""
attr :items, :list, required: true
attr :subtotal, :integer, required: true
attr :mode, :atom, default: :live
def cart_layout(assigns) do
~H"""
<div class="cart-layout">
<div class="cart-items-stack">
<%= for item <- @items do %>
<.cart_item item={item} />
<% end %>
</div>
<div>
<.order_summary subtotal={@subtotal} mode={@mode} />
</div>
</div>
"""
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,939 @@
defmodule BerrypodWeb.ShopComponents.Layout do
use Phoenix.Component
import BerrypodWeb.ShopComponents.Cart
import BerrypodWeb.ShopComponents.Content
@doc """
Renders the announcement bar.
The bar displays promotional messaging at the top of the page.
It uses CSS custom properties for theming.
## Attributes
* `theme_settings` - Required. The theme settings map.
* `message` - Optional. The announcement message to display.
Defaults to "Free delivery on orders over £40".
## Examples
<.announcement_bar theme_settings={@theme_settings} />
<.announcement_bar theme_settings={@theme_settings} message="20% off this weekend!" />
"""
attr :theme_settings, :map, required: true
attr :message, :string, default: "Sample announcement e.g. free delivery, sales, or new drops"
def announcement_bar(assigns) do
~H"""
<div class="announcement-bar">
<p>{@message}</p>
</div>
"""
end
@doc """
Renders the skip link for keyboard navigation accessibility.
This is a standard accessibility pattern that allows keyboard users
to skip directly to the main content.
"""
def skip_link(assigns) do
~H"""
<a href="#main-content" class="skip-link">
Skip to main content
</a>
"""
end
# Keys accepted by shop_layout — used by layout_assigns/1 so page templates
# can spread assigns without listing each one explicitly.
@layout_keys ~w(theme_settings logo_image header_image mode cart_items cart_count
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries)a
@doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map.
Page templates can use this instead of listing every attr explicitly:
<.shop_layout {layout_assigns(assigns)} active_page="home">
...
</.shop_layout>
"""
def layout_assigns(assigns) do
Map.take(assigns, @layout_keys)
end
@doc """
Wraps page content in the standard shop shell: container, header, footer,
cart drawer, search modal, and mobile bottom nav.
Templates pass their unique `<main>` content as the inner block.
The `error_page` flag disables the CartPersist hook and mobile bottom nav.
"""
attr :theme_settings, :map, required: true
attr :logo_image, :any, required: true
attr :header_image, :any, required: true
attr :mode, :atom, required: true
attr :cart_items, :list, required: true
attr :cart_count, :integer, required: true
attr :cart_subtotal, :string, required: true
attr :cart_total, :string, default: nil
attr :cart_drawer_open, :boolean, default: false
attr :cart_status, :string, default: nil
attr :active_page, :string, required: true
attr :error_page, :boolean, default: false
attr :is_admin, :boolean, default: false
attr :search_query, :string, default: ""
attr :search_results, :list, default: []
attr :search_open, :boolean, default: false
attr :shipping_estimate, :integer, default: nil
attr :country_code, :string, default: "GB"
attr :available_countries, :list, default: []
slot :inner_block, required: true
def shop_layout(assigns) do
~H"""
<div
id={unless @error_page, do: "shop-container"}
phx-hook={unless @error_page, do: "CartPersist"}
class="shop-container"
data-bottom-nav={!@error_page || nil}
>
<.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={@active_page}
mode={@mode}
cart_count={@cart_count}
is_admin={@is_admin}
/>
{render_slot(@inner_block)}
<.shop_footer
theme_settings={@theme_settings}
mode={@mode}
categories={assigns[:categories] || []}
/>
<.cart_drawer
cart_items={@cart_items}
subtotal={@cart_subtotal}
total={@cart_total}
cart_count={@cart_count}
mode={@mode}
open={@cart_drawer_open}
cart_status={@cart_status}
shipping_estimate={@shipping_estimate}
country_code={@country_code}
available_countries={@available_countries}
/>
<.search_modal
hint_text={~s(Try a search e.g. "mountain" or "notebook")}
search_query={@search_query}
search_results={@search_results}
search_open={@search_open}
/>
<.mobile_bottom_nav :if={!@error_page} active_page={@active_page} mode={@mode} />
</div>
"""
end
@doc """
Renders a mobile bottom navigation bar.
This component provides thumb-friendly navigation for mobile devices,
following modern UX best practices. It's hidden on larger screens where
the standard header navigation is used.
## Attributes
* `active_page` - Required. The current page identifier (e.g., "home", "collection", "about", "contact").
* `mode` - Optional. Either `:live` (default) for real navigation or
`:preview` for theme preview mode with phx-click handlers.
* `cart_count` - Optional. Number of items in cart for badge display. Default: 0.
## Examples
<.mobile_bottom_nav active_page="home" />
<.mobile_bottom_nav active_page="collection" mode={:preview} />
"""
attr :active_page, :string, required: true
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
def mobile_bottom_nav(assigns) do
~H"""
<nav
class="mobile-bottom-nav"
aria-label="Main navigation"
>
<ul>
<.mobile_nav_item
icon={:home}
label="Home"
page="home"
href="/"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:shop}
label="Shop"
page="collection"
href="/collections/all"
active_page={@active_page}
active_pages={["collection", "pdp"]}
mode={@mode}
/>
<.mobile_nav_item
icon={:about}
label="About"
page="about"
href="/about"
active_page={@active_page}
mode={@mode}
/>
<.mobile_nav_item
icon={:contact}
label="Contact"
page="contact"
href="/contact"
active_page={@active_page}
mode={@mode}
/>
</ul>
</nav>
"""
end
attr :icon, :atom, required: true
attr :label, :string, required: true
attr :page, :string, required: true
attr :href, :string, required: true
attr :active_page, :string, required: true
attr :active_pages, :list, default: nil
attr :mode, :atom, default: :live
defp mobile_nav_item(assigns) do
active_pages = assigns.active_pages || [assigns.page]
is_current = assigns.active_page in active_pages
assigns = assign(assigns, :is_current, is_current)
~H"""
<li>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={@page}
class="mobile-nav-link"
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} />
<span>{@label}</span>
</a>
<% else %>
<.link
navigate={@href}
class="mobile-nav-link"
aria-current={if @is_current, do: "page", else: nil}
>
<.nav_icon icon={@icon} />
<span>{@label}</span>
</.link>
<% end %>
</li>
"""
end
defp nav_icon(%{icon: :home} = assigns) do
~H"""
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
"""
end
defp nav_icon(%{icon: :shop} = assigns) do
~H"""
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
"""
end
defp nav_icon(%{icon: :about} = assigns) do
~H"""
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12.01" y2="8"></line>
</svg>
"""
end
defp nav_icon(%{icon: :contact} = assigns) do
~H"""
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
"""
end
@doc """
Renders the search modal overlay with live search results.
## Attributes
* `hint_text` - Hint text shown when no query is entered.
* `search_query` - Current search query string.
* `search_results` - List of Product structs matching the query.
"""
attr :hint_text, :string, default: nil
attr :search_query, :string, default: ""
attr :search_results, :list, default: []
attr :search_open, :boolean, default: false
def search_modal(assigns) do
alias Berrypod.Cart
alias Berrypod.Products.{Product, ProductImage}
assigns =
assign(
assigns,
:results_with_images,
assigns.search_results
|> Enum.with_index()
|> Enum.map(fn {product, idx} ->
image = Product.primary_image(product)
%{product: product, image_url: ProductImage.url(image, 400), idx: idx}
end)
)
~H"""
<div
id="search-modal"
class="search-modal"
style={"display: #{if @search_open, do: "flex", else: "none"};"}
phx-hook="SearchModal"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
>
<div
class="search-panel"
onclick="event.stopPropagation()"
>
<div class="search-bar">
<svg
class="search-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
id="search-input"
name="query"
class="search-input"
placeholder="Search products..."
value={@search_query}
phx-keyup="search"
phx-debounce="150"
autocomplete="off"
role="combobox"
aria-expanded={to_string(@search_results != [])}
aria-controls="search-results-list"
aria-autocomplete="list"
/>
<div
class="search-kbd"
aria-hidden="true"
>
<kbd>⌘</kbd><kbd>K</kbd>
</div>
<button
type="button"
class="search-close"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
aria-label="Close search"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="search-results">
<%= cond do %>
<% @search_results != [] -> %>
<ul id="search-results-list" role="listbox" aria-label="Search results">
<li
:for={item <- @results_with_images}
id={"search-result-#{item.idx}"}
role="option"
aria-selected="false"
>
<.link
navigate={"/products/#{item.product.slug || item.product.id}"}
class="search-result"
phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")}
>
<div
:if={item.image_url}
class="search-result-thumb"
>
<img
src={item.image_url}
alt={item.product.title}
loading="lazy"
/>
</div>
<div class="search-result-details">
<p class="search-result-title">
{item.product.title}
</p>
<p class="search-result-meta">
{item.product.category}
<span>
{Cart.format_price(item.product.cheapest_price)}
</span>
</p>
</div>
</.link>
</li>
</ul>
<% String.length(@search_query) >= 2 -> %>
<div class="search-hint">
<p>No products found for "{@search_query}"</p>
</div>
<% @hint_text != nil -> %>
<div class="search-hint">
<p>{@hint_text}</p>
</div>
<% true -> %>
<% end %>
</div>
</div>
</div>
"""
end
@doc """
Renders the shop footer with newsletter signup and links.
## Attributes
* `theme_settings` - Required. The theme settings map containing site_name.
* `mode` - Optional. Either `:live` (default) for real navigation or
`:preview` for theme preview mode with phx-click handlers.
## Examples
<.shop_footer theme_settings={@theme_settings} />
<.shop_footer theme_settings={@theme_settings} mode={:preview} />
"""
attr :theme_settings, :map, required: true
attr :mode, :atom, default: :live
attr :categories, :list, default: []
def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year)
~H"""
<footer class="shop-footer">
<div class="shop-footer-inner">
<div class="footer-grid">
<.newsletter_card variant={:inline} />
<div class="footer-links">
<div>
<h4 class="footer-heading">
Shop
</h4>
<ul class="footer-nav">
<%= if @mode == :preview do %>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="footer-link"
>
All products
</a>
</li>
<%= for category <- @categories do %>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="footer-link"
>
{category.name}
</a>
</li>
<% end %>
<% else %>
<li>
<.link
navigate="/collections/all"
class="footer-link"
>
All products
</.link>
</li>
<%= for category <- @categories do %>
<li>
<.link
navigate={"/collections/#{category.slug}"}
class="footer-link"
>
{category.name}
</.link>
</li>
<% end %>
<% end %>
</ul>
</div>
<div>
<h4 class="footer-heading">
Help
</h4>
<ul class="footer-nav">
<%= if @mode == :preview do %>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="delivery"
class="footer-link"
>
Delivery & returns
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="privacy"
class="footer-link"
>
Privacy policy
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="terms"
class="footer-link"
>
Terms of service
</a>
</li>
<li>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="contact"
class="footer-link"
>
Contact
</a>
</li>
<% else %>
<li>
<.link
navigate="/delivery"
class="footer-link"
>
Delivery & returns
</.link>
</li>
<li>
<.link
navigate="/privacy"
class="footer-link"
>
Privacy policy
</.link>
</li>
<li>
<.link
navigate="/terms"
class="footer-link"
>
Terms of service
</.link>
</li>
<li>
<.link
navigate="/contact"
class="footer-link"
>
Contact
</.link>
</li>
<% end %>
</ul>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="footer-bottom">
<p class="footer-copyright">
© {@current_year} {@theme_settings.site_name}
</p>
<.social_links />
</div>
</div>
</footer>
"""
end
@doc """
Renders the shop header with logo, navigation, and actions.
## Attributes
* `theme_settings` - Required. The theme settings map.
* `logo_image` - Optional. The logo image struct (with id, is_svg fields).
* `header_image` - Optional. The header background image struct.
* `active_page` - Optional. Current page for nav highlighting.
* `mode` - Optional. Either `:live` (default) or `:preview`.
* `cart_count` - Optional. Number of items in cart. Defaults to 0.
## Examples
<.shop_header theme_settings={@theme_settings} />
<.shop_header theme_settings={@theme_settings} mode={:preview} cart_count={2} />
"""
attr :theme_settings, :map, required: true
attr :logo_image, :map, default: nil
attr :header_image, :map, default: nil
attr :active_page, :string, default: nil
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
attr :is_admin, :boolean, default: false
def shop_header(assigns) do
~H"""
<header class="shop-header">
<%= if @theme_settings.header_background_enabled && @header_image do %>
<div style={header_background_style(@theme_settings, @header_image)} />
<% end %>
<div class="shop-logo">
<.logo_content
theme_settings={@theme_settings}
logo_image={@logo_image}
active_page={@active_page}
mode={@mode}
/>
</div>
<nav class="shop-nav">
<%= if @mode == :preview do %>
<.nav_item label="Home" page="home" active_page={@active_page} mode={:preview} />
<.nav_item
label="Shop"
page="collection"
active_page={@active_page}
mode={:preview}
active_pages={["collection", "pdp"]}
/>
<.nav_item label="About" page="about" active_page={@active_page} mode={:preview} />
<.nav_item label="Contact" page="contact" active_page={@active_page} mode={:preview} />
<% else %>
<.nav_item label="Home" href="/" active_page={@active_page} page="home" />
<.nav_item
label="Shop"
href="/collections/all"
active_page={@active_page}
page="collection"
active_pages={["collection", "pdp"]}
/>
<.nav_item label="About" href="/about" active_page={@active_page} page="about" />
<.nav_item label="Contact" href="/contact" active_page={@active_page} page="contact" />
<% end %>
</nav>
<div class="shop-actions">
<.link
:if={@is_admin}
href="/admin"
class="header-icon-btn"
aria-label="Admin"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
</.link>
<button
type="button"
class="header-icon-btn"
phx-click={Phoenix.LiveView.JS.dispatch("open-search", to: "#search-modal")}
aria-label="Search"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
</button>
<button
type="button"
class="header-icon-btn"
phx-click={open_cart_drawer_js()}
aria-label="Cart"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"></path>
<line x1="3" y1="6" x2="21" y2="6"></line>
<path d="M16 10a4 4 0 01-8 0"></path>
</svg>
<%= if @cart_count > 0 do %>
<span class="cart-badge">
{@cart_count}
</span>
<% end %>
<span class="sr-only">Cart ({@cart_count})</span>
</button>
</div>
</header>
"""
end
defp logo_url(logo_image, %{logo_recolor: true, logo_color: color}) when logo_image.is_svg do
clean_color = String.trim_leading(color, "#")
"/images/#{logo_image.id}/recolored/#{clean_color}"
end
defp logo_url(logo_image, _), do: "/image_cache/#{logo_image.id}.webp"
# Logo content that links to home, except when already on home page.
# This follows accessibility best practices - current page should not be a link.
attr :theme_settings, :map, required: true
attr :logo_image, :map, default: nil
attr :active_page, :string, default: nil
attr :mode, :atom, default: :live
defp logo_content(assigns) do
is_home = assigns.active_page == "home"
assigns = assign(assigns, :is_home, is_home)
~H"""
<%= if @is_home do %>
<.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} />
<% else %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="home"
class="shop-logo-link"
>
<.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} />
</a>
<% else %>
<.link navigate="/" class="shop-logo-link">
<.logo_inner theme_settings={@theme_settings} logo_image={@logo_image} />
</.link>
<% end %>
<% end %>
"""
end
attr :theme_settings, :map, required: true
attr :logo_image, :map, default: nil
defp logo_inner(assigns) do
~H"""
<%= case @theme_settings.logo_mode do %>
<% "text-only" -> %>
<span class="shop-logo-text">
{@theme_settings.site_name}
</span>
<% "logo-text" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@theme_settings.site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% end %>
<span class="shop-logo-text">
{@theme_settings.site_name}
</span>
<% "logo-only" -> %>
<%= if @logo_image do %>
<img
src={logo_url(@logo_image, @theme_settings)}
alt={@theme_settings.site_name}
class="shop-logo-img"
style={"height: #{@theme_settings.logo_size}px;"}
/>
<% else %>
<span class="shop-logo-text">
{@theme_settings.site_name}
</span>
<% end %>
<% _ -> %>
<span class="shop-logo-text">
{@theme_settings.site_name}
</span>
<% end %>
"""
end
defp header_background_style(settings, header_image) do
"position: absolute; top: 0; left: 0; right: 0; bottom: 0; " <>
"background-image: url('/image_cache/#{header_image.id}.webp'); " <>
"background-size: #{settings.header_zoom}%; " <>
"background-position: #{settings.header_position_x}% #{settings.header_position_y}%; " <>
"background-repeat: no-repeat; z-index: 0;"
end
# Navigation item that renders as a span (not a link) when on the current page.
# This follows accessibility best practices - current page should not be a link.
attr :label, :string, required: true
attr :page, :string, required: true
attr :active_page, :string, required: true
attr :href, :string, default: nil
attr :mode, :atom, default: :live
attr :active_pages, :list, default: nil
defp nav_item(assigns) do
# Allow matching multiple pages (e.g., "Shop" is active for both collection and pdp)
active_pages = assigns.active_pages || [assigns.page]
is_current = assigns.active_page in active_pages
assigns = assign(assigns, :is_current, is_current)
~H"""
<%= if @is_current do %>
<span class="nav-link" aria-current="page">
{@label}
</span>
<% else %>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={@page}
class="nav-link"
>
{@label}
</a>
<% else %>
<.link navigate={@href} class="nav-link">
{@label}
</.link>
<% end %>
<% end %>
"""
end
defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end
end

File diff suppressed because it is too large Load Diff