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:
524
lib/berrypod_web/components/core_components.ex
Normal file
524
lib/berrypod_web/components/core_components.ex
Normal 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
|
||||
136
lib/berrypod_web/components/layouts.ex
Normal file
136
lib/berrypod_web/components/layouts.ex
Normal 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
|
||||
108
lib/berrypod_web/components/layouts/admin.html.heex
Normal file
108
lib/berrypod_web/components/layouts/admin.html.heex
Normal 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} />
|
||||
40
lib/berrypod_web/components/layouts/admin_root.html.heex
Normal file
40
lib/berrypod_web/components/layouts/admin_root.html.heex
Normal 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>
|
||||
36
lib/berrypod_web/components/layouts/root.html.heex
Normal file
36
lib/berrypod_web/components/layouts/root.html.heex
Normal 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>
|
||||
2
lib/berrypod_web/components/layouts/shop.html.heex
Normal file
2
lib/berrypod_web/components/layouts/shop.html.heex
Normal file
@@ -0,0 +1,2 @@
|
||||
<.shop_flash_group flash={@flash} />
|
||||
{@inner_content}
|
||||
51
lib/berrypod_web/components/layouts/shop_root.html.heex
Normal file
51
lib/berrypod_web/components/layouts/shop_root.html.heex
Normal 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>
|
||||
21
lib/berrypod_web/components/page_templates.ex
Normal file
21
lib/berrypod_web/components/page_templates.ex
Normal 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
|
||||
37
lib/berrypod_web/components/page_templates/cart.html.heex
Normal file
37
lib/berrypod_web/components/page_templates/cart.html.heex
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
30
lib/berrypod_web/components/page_templates/contact.html.heex
Normal file
30
lib/berrypod_web/components/page_templates/contact.html.heex
Normal 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>
|
||||
33
lib/berrypod_web/components/page_templates/content.html.heex
Normal file
33
lib/berrypod_web/components/page_templates/content.html.heex
Normal 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>
|
||||
33
lib/berrypod_web/components/page_templates/error.html.heex
Normal file
33
lib/berrypod_web/components/page_templates/error.html.heex
Normal 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>
|
||||
31
lib/berrypod_web/components/page_templates/home.html.heex
Normal file
31
lib/berrypod_web/components/page_templates/home.html.heex
Normal 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>
|
||||
67
lib/berrypod_web/components/page_templates/pdp.html.heex
Normal file
67
lib/berrypod_web/components/page_templates/pdp.html.heex
Normal 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>
|
||||
23
lib/berrypod_web/components/shop_components.ex
Normal file
23
lib/berrypod_web/components/shop_components.ex
Normal 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
|
||||
243
lib/berrypod_web/components/shop_components/base.ex
Normal file
243
lib/berrypod_web/components/shop_components/base.ex
Normal 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
|
||||
539
lib/berrypod_web/components/shop_components/cart.ex
Normal file
539
lib/berrypod_web/components/shop_components/cart.ex
Normal 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
|
||||
1083
lib/berrypod_web/components/shop_components/content.ex
Normal file
1083
lib/berrypod_web/components/shop_components/content.ex
Normal file
File diff suppressed because it is too large
Load Diff
939
lib/berrypod_web/components/shop_components/layout.ex
Normal file
939
lib/berrypod_web/components/shop_components/layout.ex
Normal 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
|
||||
1652
lib/berrypod_web/components/shop_components/product.ex
Normal file
1652
lib/berrypod_web/components/shop_components/product.ex
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user